From 7470e3d622bd699503ead92c960305cba359ac00 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 30 Mar 2026 20:15:08 +0530 Subject: [PATCH 01/27] [feature] Made RegisteredUser model support multi-tenancy #692 - Updated the RegisteredUser model to support organization-specific records. - Changed the primary key to UUID and added organization as a nullable ForeignKey. - Modified related code across the application to handle multiple registered users per organization. - Updated tests to reflect changes in the RegisteredUser model and ensure proper functionality. - Added migration scripts to handle the transition from the old model to the new schema. Closes #692 --- openwisp_radius/admin.py | 20 +- openwisp_radius/api/freeradius_views.py | 13 +- openwisp_radius/api/serializers.py | 44 +++- openwisp_radius/api/utils.py | 16 +- openwisp_radius/api/views.py | 30 ++- openwisp_radius/base/admin_filters.py | 6 +- openwisp_radius/base/models.py | 88 ++++++- .../integrations/monitoring/tasks.py | 21 +- .../monitoring/tests/test_metrics.py | 6 +- .../commands/base/delete_unverified_users.py | 8 +- .../0043_registereduser_add_uuid.py | 224 ++++++++++++++++++ openwisp_radius/saml/backends.py | 5 +- openwisp_radius/saml/views.py | 9 +- openwisp_radius/social/views.py | 9 +- openwisp_radius/tests/mixins.py | 8 +- openwisp_radius/tests/test_admin.py | 22 +- openwisp_radius/tests/test_api/test_api.py | 21 +- .../tests/test_api/test_phone_verification.py | 73 ++++-- .../tests/test_api/test_rest_token.py | 8 +- openwisp_radius/tests/test_batch_add_users.py | 5 +- openwisp_radius/tests/test_commands.py | 16 +- openwisp_radius/tests/test_saml/test_views.py | 3 +- openwisp_radius/tests/test_selenium.py | 1 + openwisp_radius/tests/test_social.py | 7 +- openwisp_radius/tests/test_tasks.py | 73 ++++-- openwisp_radius/tests/test_token.py | 5 +- .../tests/test_users_integration.py | 16 +- runtests | 2 +- .../0032_registered_user_multitenant.py | 224 ++++++++++++++++++ 29 files changed, 840 insertions(+), 143 deletions(-) create mode 100644 openwisp_radius/migrations/0043_registereduser_add_uuid.py create mode 100644 tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index ebd816de..9f71e398 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -3,7 +3,7 @@ from django import forms from django.conf import settings from django.contrib import admin, messages -from django.contrib.admin import ModelAdmin, StackedInline +from django.contrib.admin import ModelAdmin, StackedInline, TabularInline from django.contrib.admin.utils import model_ngettext from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied @@ -534,11 +534,15 @@ def has_change_permission(self, request, obj=None): return False -class RegisteredUserInline(StackedInline): +class RegisteredUserInline(TabularInline): model = RegisteredUser form = AlwaysHasChangedForm extra = 0 - readonly_fields = ("modified",) + readonly_fields = ( + "organization", + "modified", + ) + fields = ("organization", "method", "is_verified", "modified") def has_delete_permission(self, request, obj=None): return False @@ -549,12 +553,17 @@ def has_delete_permission(self, request, obj=None): RadiusUserGroupInline, PhoneTokenInline, ] -UserAdmin.list_filter += (RegisteredUserFilter, "registered_user__method") +UserAdmin.list_filter += (RegisteredUserFilter, "registered_users__method") def get_is_verified(self, obj): try: - value = "yes" if obj.registered_user.is_verified else "no" + if not obj.registered_users.exists(): + value = "unknown" + elif obj.registered_users.filter(is_verified=True).exists(): + value = "yes" + else: + value = "no" except Exception: value = "unknown" icon_url = static(f"admin/img/icon-{value}.svg") @@ -564,7 +573,6 @@ def get_is_verified(self, obj): UserAdmin.get_is_verified = get_is_verified UserAdmin.get_is_verified.short_description = _("Verified") UserAdmin.list_display.insert(3, "get_is_verified") -UserAdmin.list_select_related = ("registered_user",) class OrganizationRadiusSettingsInline(admin.StackedInline): diff --git a/openwisp_radius/api/freeradius_views.py b/openwisp_radius/api/freeradius_views.py index b69232e5..b59cd59e 100644 --- a/openwisp_radius/api/freeradius_views.py +++ b/openwisp_radius/api/freeradius_views.py @@ -290,7 +290,7 @@ def get_user(self, request, username, password): """ conditions = self._get_user_query_conditions(request) try: - user = auth_backend.get_users(username).filter(conditions)[0] + user = auth_backend.get_users(username).filter(conditions).distinct()[0] except IndexError: return None # ensure user is member of the authenticated org @@ -409,8 +409,11 @@ def _get_user_query_conditions(self, request): # just ensure user is active if not needs_verification: return is_active - # if identity verification is enabled - is_verified = Q(registered_user__is_verified=True) + organization_id = request._auth + org_or_global = Q(registered_users__organization_id=organization_id) | Q( + registered_users__organization__isnull=True + ) + is_verified = Q(registered_users__is_verified=True) & org_or_global AUTHORIZE_UNVERIFIED = registration.AUTHORIZE_UNVERIFIED # and no method should authorize unverified users # ensure user is active AND verified @@ -420,7 +423,9 @@ def _get_user_query_conditions(self, request): # ensure user is active AND # (user is verified OR user uses one of these methods) else: - authorize_unverified = Q(registered_user__method__in=AUTHORIZE_UNVERIFIED) + authorize_unverified = ( + Q(registered_users__method__in=AUTHORIZE_UNVERIFIED) & org_or_global + ) return is_active & (is_verified | authorize_unverified) def authenticate_user(self, request, user, password): diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index b9b01165..4c62c812 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -688,9 +688,11 @@ def save(self, request): # the custom_signup method contains the openwisp specific logic self.custom_signup(request, user) # create a RegisteredUser object for every user that registers through API - RegisteredUser.objects.create( + org = self.context["view"].organization + RegisteredUser.objects.get_or_create( user=user, - method=self.validated_data["method"], + organization=org, + defaults={"method": self.validated_data["method"]}, ) setup_user_email(request, user, []) return user @@ -753,8 +755,14 @@ def save(self): # yet, tha will be done by the phone token validation view # once the phone number has been validated # at this point we flag the user as unverified again - self.user.registered_user.is_verified = False - self.user.registered_user.save() + org = self.context["view"].organization + reg_user, _ = RegisteredUser.get_or_create_for_user_and_org( + user=self.user, + organization=org, + defaults={"is_verified": False, "method": ""}, + ) + reg_user.is_verified = False + reg_user.save() class RadiusUserSerializer(serializers.ModelSerializer): @@ -762,11 +770,8 @@ class RadiusUserSerializer(serializers.ModelSerializer): Used to return information about the logged in user """ - is_verified = serializers.BooleanField(source="registered_user.is_verified") - method = serializers.CharField( - source="registered_user.method", - allow_null=True, - ) + is_verified = serializers.SerializerMethodField() + method = serializers.SerializerMethodField() password_expired = serializers.BooleanField(source="has_password_expired") radius_user_token = serializers.CharField(source="radius_token.key", default=None) @@ -786,3 +791,24 @@ class Meta: "password_expired", "radius_user_token", ] + + def _get_registered_user(self, obj): + view = self.context.get("view") + organization = getattr(view, "organization", None) + org_reg_user = None + global_reg_user = None + for ru in obj.registered_users.all(): + if organization and ru.organization_id == organization.pk: + org_reg_user = ru + break + elif ru.organization_id is None: + global_reg_user = ru + return org_reg_user or global_reg_user + + def get_is_verified(self, obj): + reg_user = self._get_registered_user(obj) + return reg_user.is_verified if reg_user else None + + def get_method(self, obj): + reg_user = self._get_registered_user(obj) + return reg_user.method if reg_user else None diff --git a/openwisp_radius/api/utils.py b/openwisp_radius/api/utils.py index 6d742c57..94ed98a9 100644 --- a/openwisp_radius/api/utils.py +++ b/openwisp_radius/api/utils.py @@ -30,8 +30,16 @@ def _needs_identity_verification(self, organization_filter_kwargs={}, org=None): except ObjectDoesNotExist: return app_settings.NEEDS_IDENTITY_VERIFICATION - def is_identity_verified_strong(self, user): - try: - return user.registered_user.is_identity_verified_strong - except ObjectDoesNotExist: + def is_identity_verified_strong(self, user, organization=None): + reg_user = None + global_reg_user = None + for ru in user.registered_users.all(): + if organization and ru.organization_id == organization.pk: + reg_user = ru + break + elif ru.organization_id is None: + global_reg_user = ru + reg_user = reg_user or global_reg_user + if reg_user is None: return False + return reg_user.is_identity_verified_strong diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 07c4bd37..059015dd 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -92,6 +92,7 @@ Organization = swapper.load_model("openwisp_users", "Organization") OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") PhoneToken = load_model("PhoneToken") +RegisteredUser = load_model("RegisteredUser") RadiusAccounting = load_model("RadiusAccounting") RadiusToken = load_model("RadiusToken") RadiusBatch = load_model("RadiusBatch") @@ -321,7 +322,7 @@ def post(self, request, *args, **kwargs): # If identity verification is required, check if user is verified if self._needs_identity_verification( {"slug": kwargs["slug"]} - ) and not self.is_identity_verified_strong(user): + ) and not self.is_identity_verified_strong(user, self.organization): status_code = 401 return Response(response, status=status_code) @@ -337,7 +338,7 @@ def validate_membership(self, user): ): if self._needs_identity_verification( org=self.organization - ) and not self.is_identity_verified_strong(user): + ) and not self.is_identity_verified_strong(user, self.organization): raise PermissionDenied try: org_user = OrganizationUser( @@ -383,9 +384,15 @@ def post(self, request, *args, **kwargs): response = {"response_code": "BLANK_OR_INVALID_TOKEN"} if request_token: try: - token = UserToken.objects.select_related( - "user", "user__registered_user" - ).get(key=request_token) + token = ( + UserToken.objects.select_related( + "user", + ) + .prefetch_related( + "user__registered_users", + ) + .get(key=request_token) + ) except UserToken.DoesNotExist: pass else: @@ -395,7 +402,7 @@ def post(self, request, *args, **kwargs): ) # user may be in the process of changing the phone number # in that case show the new phone number (which is not verified yet) - if not self.is_identity_verified_strong(user): + if not self.is_identity_verified_strong(user, self.organization): phone_token = ( PhoneToken.objects.filter(user=user) .order_by("-created") @@ -753,8 +760,13 @@ def post(self, request, *args, **kwargs): if not is_valid: return self._error_response(_("Invalid code.")) else: - user.registered_user.is_verified = True - user.registered_user.method = "mobile_phone" + reg_user, _ = RegisteredUser.get_or_create_for_user_and_org( + user=user, + organization=self.organization, + defaults={"is_verified": False, "method": ""}, + ) + reg_user.is_verified = True + reg_user.method = "mobile_phone" user.is_active = True # Update username if phone_number is used as username if user.username == user.phone_number: @@ -763,7 +775,7 @@ def post(self, request, *args, **kwargs): # we can write it to the user field user.phone_number = phone_token.phone_number user.save() - user.registered_user.save() + reg_user.save() # delete any radius token cache key if present cache.delete(f"rt-{phone_token.phone_number}") return Response(None, status=200) diff --git a/openwisp_radius/base/admin_filters.py b/openwisp_radius/base/admin_filters.py index 5fd73991..d8c1f7d4 100644 --- a/openwisp_radius/base/admin_filters.py +++ b/openwisp_radius/base/admin_filters.py @@ -15,7 +15,9 @@ def lookups(self, request, model_admin): def queryset(self, request, queryset): if self.value() == "unknown": - return queryset.filter(registered_user__isnull=True) + return queryset.filter(registered_users__isnull=True) elif self.value(): - return queryset.filter(registered_user__is_verified=self.value() == "true") + return queryset.filter( + registered_users__is_verified=self.value() == "true" + ).distinct() return queryset diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index 808b8640..81a54a4a 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -4,6 +4,7 @@ import logging import os import string +import uuid from datetime import timedelta from io import StringIO @@ -1058,7 +1059,11 @@ def save_user(self, user): OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") RegisteredUser = swapper.load_model("openwisp_radius", "RegisteredUser") user.save() - registered_user = RegisteredUser(user=user, method="manual") + registered_user = RegisteredUser( + user=user, + method="manual", + organization=self.organization, + ) if self.organization.radius_settings.needs_identity_verification: registered_user.is_verified = True registered_user.save() @@ -1570,14 +1575,12 @@ def is_valid(self, token): return self.verified def _validate_already_verified(self): - try: - if self.user.registered_user.is_verified: - logger.warning(f"User {self.user.pk} is already verified") - raise exceptions.UserAlreadyVerified( - _("This user has been already verified.") - ) - except ObjectDoesNotExist: - pass + RegisteredUser = swapper.load_model("openwisp_radius", "RegisteredUser") + if RegisteredUser.objects.filter(user=self.user, is_verified=True).exists(): + logger.warning(f"User {self.user.pk} is already verified") + raise exceptions.UserAlreadyVerified( + _("This user has been already verified.") + ) def __check(self, token): self._validate_already_verified() @@ -1602,12 +1605,23 @@ def __check(self, token): return token == self.token -class AbstractRegisteredUser(models.Model): - user = models.OneToOneField( +class AbstractRegisteredUser(UUIDModel): + user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="registered_user", - primary_key=True, + related_name="registered_users", + ) + organization = models.ForeignKey( + swapper.get_model_name("openwisp_users", "Organization"), + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="registered_users", + verbose_name=_("organization"), + help_text=( + "The organization this registration info belongs to. " + "If null, applies to all orgs without specific requirements." + ), ) method = models.CharField( _("registration method"), @@ -1649,6 +1663,54 @@ class Meta: abstract = True verbose_name = _("Registration Information") verbose_name_plural = verbose_name + constraints = [ + models.UniqueConstraint( + fields=["user", "organization"], + name="unique_registered_user_per_org", + ), + models.UniqueConstraint( + fields=["user"], + condition=Q(organization__isnull=True), + name="unique_global_registered_user", + ), + ] + + def clean(self): + super().clean() + Model = self._meta.model + qs = Model.objects.filter(user=self.user, organization=self.organization) + if self.pk: + qs = qs.exclude(pk=self.pk) + if qs.exists(): + raise ValidationError( + _("A registration record already exists for this user/organization.") + ) + + @classmethod + def get_for_user_and_org(cls, user, organization): + try: + return cls.objects.get(user=user, organization=organization) + except cls.DoesNotExist: + return None + + @classmethod + def get_or_create_for_user_and_org(cls, user, organization, defaults=None): + defaults = defaults or {} + return cls.objects.get_or_create( + user=user, organization=organization, defaults=defaults + ) + + @classmethod + def get_global_or_org_specific(cls, user, organization=None): + if organization: + try: + return cls.objects.get(user=user, organization=organization) + except cls.DoesNotExist: + pass + try: + return cls.objects.get(user=user, organization__isnull=True) + except cls.DoesNotExist: + return None @classmethod def unverify_inactive_users(cls): diff --git a/openwisp_radius/integrations/monitoring/tasks.py b/openwisp_radius/integrations/monitoring/tasks.py index f251edc3..c19a16aa 100644 --- a/openwisp_radius/integrations/monitoring/tasks.py +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -75,9 +75,9 @@ def _write_user_signup_metric_for_all(metric_key): ) ) # Some manually created users, like superuser may not have a - # RegisteredUser object. We would could them with "unspecified" method + # RegisteredUser object. We would count them with "unspecified" method users_without_registereduser_query = User.objects.filter( - registered_user__isnull=True + registered_users__isnull=True ) if metric_key == "user_signups": users_without_registereduser_query = users_without_registereduser_query.filter( @@ -131,7 +131,7 @@ def _write_user_signup_metrics_for_orgs(metric_key): # which do not have related RegisteredUser object. Add the count # of such users with the "unspecified" method. users_without_registereduser_query = OrganizationUser.objects.filter( - user__registered_user__isnull=True + user__registered_users__isnull=True ) if metric_key == "user_signups": users_without_registereduser_query = users_without_registereduser_query.filter( @@ -182,18 +182,21 @@ def post_save_radiusaccounting( called_station_id, time=None, ): - try: - registration_method = ( - RegisteredUser.objects.only("method").get(user__username=username).method - ) - except RegisteredUser.DoesNotExist: + registration_method = ( + RegisteredUser.objects.only("method") + .filter(user__username=username) + .filter(Q(organization_id=organization_id) | Q(organization__isnull=True)) + .first() + ) + if registration_method is None: logger.info( f'RegisteredUser object not found for "{username}".' ' The metric will be written with "unspecified" registration method!' ) registration_method = "unspecified" else: - registration_method = clean_registration_method(registration_method) + registration_method = registration_method.method + registration_method = clean_registration_method(registration_method) device_lookup = Q(mac_address__iexact=called_station_id.replace("-", ":")) extra_tags = { "method": registration_method, diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index 8a3f6dd7..d1a754e2 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -22,7 +22,11 @@ @tag("radius_monitoring") class TestMetrics(CreateDeviceMonitoringMixin, BaseTransactionTestCase): def _create_registered_user(self, **kwargs): - options = {"is_verified": False, "method": "mobile_phone"} + options = { + "is_verified": False, + "method": "mobile_phone", + "organization": self.default_org, + } options.update(**kwargs) if "user" not in options: options["user"] = self._create_user() diff --git a/openwisp_radius/management/commands/base/delete_unverified_users.py b/openwisp_radius/management/commands/base/delete_unverified_users.py index ebefc038..8b55906a 100644 --- a/openwisp_radius/management/commands/base/delete_unverified_users.py +++ b/openwisp_radius/management/commands/base/delete_unverified_users.py @@ -35,12 +35,12 @@ def handle(self, *args, **options): qs = User.objects.filter( date_joined__lt=days, - registered_user__isnull=False, - registered_user__is_verified=False, + registered_users__isnull=False, + registered_users__is_verified=False, is_staff=False, - ) + ).distinct() if exclude_methods: - qs = qs.exclude(registered_user__method__in=exclude_methods) + qs = qs.exclude(registered_users__method__in=exclude_methods) for user in qs.iterator(): if not RadiusAccounting.objects.filter(username=user.username).exists(): diff --git a/openwisp_radius/migrations/0043_registereduser_add_uuid.py b/openwisp_radius/migrations/0043_registereduser_add_uuid.py new file mode 100644 index 00000000..8b3879f3 --- /dev/null +++ b/openwisp_radius/migrations/0043_registereduser_add_uuid.py @@ -0,0 +1,224 @@ +import uuid + +import django +import django.db.models.deletion +import swapper +from django.conf import settings +from django.db import connection, migrations, models + + +def get_swapped_model(apps, app_name, model_name): + model_path = swapper.get_model_name(app_name, model_name) + app, model = swapper.split(model_path) + return apps.get_model(app, model) + + +def recreate_table_forward(apps, schema_editor): + """ + Recreate registereduser table with new schema: + - UUID id as primary key + - user as ForeignKey (not primary key) + - organization as nullable ForeignKey + Then copy data from old table. + """ + RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") + db_table = RegisteredUser._meta.db_table + User = apps.get_model(settings.AUTH_USER_MODEL) + user_table = User._meta.db_table + + with connection.cursor() as cursor: + # Read existing data (openwisp_radius model has extra 'details' field) + cursor.execute( + f'SELECT "user_id", "is_verified", "method", "modified", "details" ' + f'FROM "{db_table}"' + ) + existing_data = cursor.fetchall() + + # Drop old table + cursor.execute(f'DROP TABLE IF EXISTS "{db_table}"') + + vendor = connection.vendor + if vendor == "sqlite": + cursor.execute( + f'CREATE TABLE "{db_table}" (' + f'"id" char(32) NOT NULL PRIMARY KEY, ' + f'"user_id" integer NOT NULL REFERENCES "{user_table}" ("id") ' + f"DEFERRABLE INITIALLY DEFERRED, " + f'"is_verified" bool NOT NULL, ' + f'"method" varchar(16) NOT NULL, ' + f'"modified" datetime NULL, ' + f'"details" varchar(64) NULL, ' + f'"organization_id" char(32) NULL REFERENCES ' + f'"openwisp_users_organization" ("id") ' + f"DEFERRABLE INITIALLY DEFERRED" + f")" + ) + else: + cursor.execute( + f'CREATE TABLE "{db_table}" (' + f'"id" uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), ' + f'"user_id" integer NOT NULL REFERENCES "{user_table}" ("id") ' + f"DEFERRABLE INITIALLY DEFERRED, " + f'"is_verified" boolean NOT NULL, ' + f'"method" varchar(16) NOT NULL, ' + f'"modified" timestamp with time zone NULL, ' + f'"details" varchar(64) NULL, ' + f'"organization_id" uuid NULL REFERENCES ' + f'"openwisp_users_organization" ("id") ' + f"DEFERRABLE INITIALLY DEFERRED" + f")" + ) + + # Create indexes + cursor.execute( + f'CREATE INDEX "{db_table}_user_id_idx" ON "{db_table}" ("user_id")' + ) + cursor.execute( + f'CREATE INDEX "{db_table}_org_id_idx" ON "{db_table}" ("organization_id")' + ) + + # Re-insert data (all as global records initially) + for user_id, is_verified, method, modified, details in existing_data: + new_id = uuid.uuid4().hex if vendor == "sqlite" else str(uuid.uuid4()) + cursor.execute( + f'INSERT INTO "{db_table}" ' + f'("id", "user_id", "is_verified", "method", "modified", ' + f'"details", "organization_id") VALUES (%s, %s, %s, %s, %s, %s, %s)', + [new_id, user_id, is_verified, method, modified, details, None], + ) + + +def migrate_registered_users_forward(apps, schema_editor): + """ + For each existing RegisteredUser (global), find all OrganizationUser + records for that user and create one RegisteredUser per organization. + """ + RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") + OrganizationUser = get_swapped_model(apps, "openwisp_users", "OrganizationUser") + + for reg_user in RegisteredUser.objects.filter(organization__isnull=True): + org_users = OrganizationUser.objects.filter(user_id=reg_user.user_id) + if org_users.exists(): + for org_user in org_users: + if not RegisteredUser.objects.filter( + user_id=reg_user.user_id, + organization_id=org_user.organization_id, + ).exists(): + RegisteredUser.objects.create( + id=uuid.uuid4(), + user_id=reg_user.user_id, + organization_id=org_user.organization_id, + is_verified=reg_user.is_verified, + method=reg_user.method, + ) + # Delete the original global record since we now have org-specific ones + reg_user.delete() + + +def migrate_registered_users_reverse(apps, schema_editor): + """ + Reverse migration: consolidate per-org records back to global. + """ + RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") + + user_ids = ( + RegisteredUser.objects.filter(organization__isnull=False) + .values_list("user_id", flat=True) + .distinct() + ) + for user_id in user_ids: + org_records = RegisteredUser.objects.filter( + user_id=user_id, organization__isnull=False + ).order_by("-is_verified", "method") + best = org_records.first() + if best: + global_exists = RegisteredUser.objects.filter( + user_id=user_id, organization__isnull=True + ).exists() + if not global_exists: + RegisteredUser.objects.create( + id=uuid.uuid4(), + user_id=user_id, + organization=None, + is_verified=best.is_verified, + method=best.method, + ) + org_records.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("openwisp_radius", "0042_set_existing_batches_completed"), + ] + + operations = [ + # Step 1: Recreate the table with new schema (UUID pk, ForeignKey user, organization) + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunPython( + recreate_table_forward, + migrations.RunPython.noop, + ), + ], + state_operations=[ + migrations.AddField( + model_name="registereduser", + name="id", + field=models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + migrations.AlterField( + model_name="registereduser", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="registered_users", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="registereduser", + name="organization", + field=models.ForeignKey( + blank=True, + help_text=( + "The organization this registration info belongs to. " + "If null, applies to all orgs without specific requirements." + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="registered_users", + to="openwisp_users.organization", + verbose_name="organization", + ), + ), + ], + ), + # Step 2: Data migration - create per-org records + migrations.RunPython( + migrate_registered_users_forward, + migrate_registered_users_reverse, + ), + # Step 3: Add unique constraints + migrations.AddConstraint( + model_name="registereduser", + constraint=models.UniqueConstraint( + fields=["user", "organization"], + name="unique_registered_user_per_org", + ), + ), + migrations.AddConstraint( + model_name="registereduser", + constraint=models.UniqueConstraint( + condition=models.Q(("organization__isnull", True)), + fields=["user"], + name="unique_global_registered_user", + ), + ), + ] diff --git a/openwisp_radius/saml/backends.py b/openwisp_radius/saml/backends.py index f61d5d55..bf471870 100644 --- a/openwisp_radius/saml/backends.py +++ b/openwisp_radius/saml/backends.py @@ -14,7 +14,10 @@ def _update_user(self, user, attributes, attribute_mapping, force_save=False): # with SAML registration method. try: attribute_mapping = attribute_mapping.copy() - if user.registered_user.method != "saml": + # Check if any of the user's registered_users records + # were NOT created via SAML + has_non_saml = user.registered_users.exclude(method="saml").exists() + if has_non_saml: for key, value in attribute_mapping.items(): if "username" in value: break diff --git a/openwisp_radius/saml/views.py b/openwisp_radius/saml/views.py index 95bf5a25..ca3fab38 100644 --- a/openwisp_radius/saml/views.py +++ b/openwisp_radius/saml/views.py @@ -72,10 +72,13 @@ def post_login_hook(self, request, user, session_info): orgUser.full_clean() orgUser.save() try: - user.registered_user - except ObjectDoesNotExist: + user.registered_users.get(organization=org) + except RegisteredUser.DoesNotExist: registered_user = RegisteredUser( - user=user, method="saml", is_verified=app_settings.SAML_IS_VERIFIED + user=user, + organization=org, + method="saml", + is_verified=app_settings.SAML_IS_VERIFIED, ) registered_user.full_clean() registered_user.save() diff --git a/openwisp_radius/social/views.py b/openwisp_radius/social/views.py index cc50a3f8..5491cdf6 100644 --- a/openwisp_radius/social/views.py +++ b/openwisp_radius/social/views.py @@ -47,10 +47,13 @@ def authorize(self, request, org, *args, **kwargs): orgUser.full_clean() orgUser.save() try: - user.registered_user - except ObjectDoesNotExist: + user.registered_users.get(organization=org) + except RegisteredUser.DoesNotExist: registered_user = RegisteredUser( - user=user, method="social_login", is_verified=False + user=user, + organization=org, + method="social_login", + is_verified=False, ) registered_user.full_clean() registered_user.save() diff --git a/openwisp_radius/tests/mixins.py b/openwisp_radius/tests/mixins.py index 1852116d..01e39c19 100644 --- a/openwisp_radius/tests/mixins.py +++ b/openwisp_radius/tests/mixins.py @@ -97,10 +97,10 @@ def _get_user_edit_form_inline_params(self, user, organization): "phonetoken_set-MIN_NUM_FORMS": 0, "phonetoken_set-MAX_NUM_FORMS": 0, # registered user inline - "registered_user-TOTAL_FORMS": 0, - "registered_user-INITIAL_FORMS": 0, - "registered_user-MIN_NUM_FORMS": 0, - "registered_user-MAX_NUM_FORMS": 0, + "registered_users-TOTAL_FORMS": 0, + "registered_users-INITIAL_FORMS": 0, + "registered_users-MIN_NUM_FORMS": 0, + "registered_users-MAX_NUM_FORMS": 0, # radius token inline "radius_token-TOTAL_FORMS": "0", "radius_token-INITIAL_FORMS": "0", diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index bc829810..b0766d5a 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -1359,7 +1359,7 @@ def test_inline_registered_user(self): with self.subTest("Inline exists"): response = self.client.get(url) - self.assertContains(response, "id_registered_user-TOTAL_FORMS") + self.assertContains(response, "id_registered_users-TOTAL_FORMS") with self.subTest("Register new choice"): register_registration_method("national_id", "National ID") @@ -1416,7 +1416,10 @@ def test_get_is_verified_user_admin_list(self): verified.full_clean() verified.save() RegisteredUser.objects.create( - user=verified, method="mobile_phone", is_verified=True + user=verified, + organization=self.default_org, + method="mobile_phone", + is_verified=True, ) unverified = User.objects.create( username="unverified", password="unverified", email="unverified@test.com" @@ -1424,7 +1427,10 @@ def test_get_is_verified_user_admin_list(self): unverified.full_clean() unverified.save() RegisteredUser.objects.create( - user=unverified, method="mobile_phone", is_verified=False + user=unverified, + organization=self.default_org, + method="mobile_phone", + is_verified=False, ) app_label = User._meta.app_label url = reverse(f"admin:{app_label}_user_changelist") @@ -1449,7 +1455,10 @@ def test_registered_user_filter(self): verified.full_clean() verified.save() RegisteredUser.objects.create( - user=verified, method="mobile_phone", is_verified=True + user=verified, + organization=self.default_org, + method="mobile_phone", + is_verified=True, ) unverified = User.objects.create( username="unverified", password="unverified", email="unverified@test.com" @@ -1457,7 +1466,10 @@ def test_registered_user_filter(self): unverified.full_clean() unverified.save() RegisteredUser.objects.create( - user=unverified, method="mobile_phone", is_verified=False + user=unverified, + organization=self.default_org, + method="mobile_phone", + is_verified=False, ) app_label = User._meta.app_label url = reverse(f"admin:{app_label}_user_changelist") diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index d0a6f3d5..751b41dc 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -159,7 +159,10 @@ def test_register_201(self): user = User.objects.get(email=self._test_email) self.assertTrue(user.is_member(self.default_org)) self.assertTrue(user.is_active) - self.assertFalse(user.registered_user.is_verified) + self.assertEqual( + user.registered_users.get(organization=self.default_org).is_verified, + False, + ) def test_register_400_password(self): response = self._register_user( @@ -319,11 +322,15 @@ def test_register_duplicate_different_org(self): def test_radius_user_serializer(self): self._register_user() try: - user = User.objects.select_related("radius_token", "registered_user").get( - email=self._test_email + user = ( + User.objects.select_related("radius_token") + .prefetch_related("registered_users") + .get(email=self._test_email) ) - admin = User.objects.select_related("radius_token", "registered_user").get( - username="admin" + admin = ( + User.objects.select_related("radius_token") + .prefetch_related("registered_users") + .get(username="admin") ) except User.DoesNotExist as e: self.fail(f"user not found: {e}") @@ -343,9 +350,9 @@ def test_radius_user_serializer(self): "birth_date": user.birth_date, "location": user.location, "is_active": user.is_active, - "is_verified": user.registered_user.is_verified, + "is_verified": user.registered_users.first().is_verified, "password_expired": user.has_password_expired(), - "method": user.registered_user.method, + "method": user.registered_users.first().method, "radius_user_token": user.radius_token.key, }, ) diff --git a/openwisp_radius/tests/test_api/test_phone_verification.py b/openwisp_radius/tests/test_api/test_phone_verification.py index c781be2f..3812dcdb 100644 --- a/openwisp_radius/tests/test_api/test_phone_verification.py +++ b/openwisp_radius/tests/test_api/test_phone_verification.py @@ -62,7 +62,10 @@ def test_register_201_mobile_phone_verification(self): user.phone_number, self._extra_registration_params["phone_number"] ) self.assertTrue(user.is_active) - self.assertFalse(user.registered_user.is_verified) + self.assertEqual( + user.registered_users.get(organization=self.default_org).is_verified, + False, + ) def test_register_phone_required(self): self.assertEqual(User.objects.count(), 0) @@ -215,8 +218,9 @@ def test_create_phone_token_400_validation_error(self): def test_create_phone_token_400_user_already_verified(self): self._register_user() token = Token.objects.last() - token.user.registered_user.is_verified = True - token.user.registered_user.save() + reg_user = token.user.registered_users.get(organization=self.default_org) + reg_user.is_verified = True + reg_user.save() token.user.save() url = reverse("radius:phone_token_create", args=[self.default_org.slug]) r = self.client.post(url, HTTP_AUTHORIZATION=f"Bearer {token.key}") @@ -335,7 +339,10 @@ def test_phone_token_status_400_not_member(self): def test_validate_phone_token_200(self): self.test_create_phone_token_201() user = User.objects.get(email=self._test_email) - self.assertNotEqual(user.registered_user.modified, _TEST_DATE) + self.assertNotEqual( + user.registered_users.get(organization=self.default_org).modified, + _TEST_DATE, + ) user_token = Token.objects.filter(user=user).last() phone_token = PhoneToken.objects.filter(user=user).last() # generate entropy to ensure correct token is used @@ -362,9 +369,10 @@ def test_validate_phone_token_200(self): self.assertEqual(phone_token.attempts, 1) user.refresh_from_db() self.assertTrue(user.is_active) - self.assertTrue(user.registered_user.is_verified) - self.assertEqual(user.registered_user.modified, parser.parse(_TEST_DATE)) - self.assertEqual(user.registered_user.method, "mobile_phone") + reg_user = user.registered_users.get(organization=self.default_org) + self.assertEqual(reg_user.is_verified, True) + self.assertEqual(reg_user.modified, parser.parse(_TEST_DATE)) + self.assertEqual(reg_user.method, "mobile_phone") self.assertIsNone(cache.get(cache_key)) @capture_any_output() @@ -448,7 +456,10 @@ def test_validate_phone_token_400_max_attempts(self): ) user.refresh_from_db() self.assertTrue(user.is_active) - self.assertFalse(user.registered_user.is_verified) + self.assertEqual( + user.registered_users.get(organization=self.default_org).is_verified, + False, + ) def test_validate_phone_token_401(self): url = reverse("radius:phone_token_validate", args=[self.default_org.slug]) @@ -459,8 +470,9 @@ def test_validate_phone_token_401(self): def test_validate_phone_token_400_user_already_verified(self): self.test_create_phone_token_201() user = User.objects.get(email=self._test_email) - user.registered_user.is_verified = True - user.registered_user.save() + reg_user = user.registered_users.get(organization=self.default_org) + reg_user.is_verified = True + reg_user.save() user.save() user_token = Token.objects.filter(user=user).last() phone_token = PhoneToken.objects.filter(user=user).last() @@ -532,7 +544,10 @@ def test_change_phone_number_200(self): self.assertTrue(user.is_active) with self.subTest("user is flagged as unverified"): - self.assertFalse(user.registered_user.is_verified) + self.assertEqual( + user.registered_users.get(organization=self.default_org).is_verified, + False, + ) with self.subTest("test verification"): code = phone_token_qs.first().token @@ -547,7 +562,10 @@ def test_change_phone_number_200(self): self.assertEqual(phone_token_qs.count(), 2) user.refresh_from_db() self.assertTrue(user.is_active) - self.assertTrue(user.registered_user.is_verified) + self.assertEqual( + user.registered_users.get(organization=self.default_org).is_verified, + True, + ) self.assertEqual(user.phone_number, new_phone_number) def test_change_phone_number_400_same_number(self): @@ -729,8 +747,9 @@ def test_change_phone_number_restriction(self): self.assertEqual(phone_token_qs.count(), 1) with self.subTest("test change number allowed at org level"): - user.registered_user.is_verified = False - user.registered_user.save() + reg_user = user.registered_users.get(organization=self.default_org) + reg_user.is_verified = False + reg_user.save() radius_settings = self.default_org.radius_settings radius_settings.allowed_mobile_prefixes = "+1" radius_settings.full_clean() @@ -781,7 +800,10 @@ def _test_change_phone_number_sms_on_helper(self, is_active): self.assertEqual(phone_token_qs.first().phone_number, new_phone_number) user.refresh_from_db() self.assertEqual(user.phone_number, old_phone_number) - self.assertFalse(user.registered_user.is_verified) + self.assertEqual( + user.registered_users.get(organization=self.default_org).is_verified, + False, + ) else: self.assertEqual(r.status_code, 401) @@ -868,16 +890,17 @@ def test_user_phone_number_unique(self): user = User.objects.get(email="user2@gmail.com") user.is_active = True user.save() - user.registered_user.is_verified = True - user.registered_user.save() + reg_user = user.registered_users.get(organization=self.default_org) + reg_user.is_verified = True + reg_user.save() # Testing for Phone token validation error due to same phone number , self._test_phone_number_unique_helper("+23767779235") user.refresh_from_db() - user.registered_user.refresh_from_db() + reg_user.refresh_from_db() # is_active state of user should not change because an error # occurred during phone token creation. - self.assertTrue(user.is_active) - self.assertTrue(user.registered_user.is_verified) + self.assertEqual(user.is_active, True) + self.assertEqual(reg_user.is_verified, True) @capture_stdout() def test_phone_number_change_update_username(self): @@ -887,8 +910,9 @@ def test_phone_number_change_update_username(self): # Mock verified user has registered with only phone_number user.username = user.phone_number user.save() - user.registered_user.is_verified = True - user.registered_user.save() + reg_user = user.registered_users.get(organization=self.default_org) + reg_user.is_verified = True + reg_user.save() PhoneToken.objects.all().delete() # Update phone_number @@ -950,7 +974,10 @@ def test_register_201_phone_number_empty(self): self.assertTrue(user.is_member(self.default_org)) self.assertEqual(user.phone_number, None) self.assertTrue(user.is_active) - self.assertFalse(user.registered_user.is_verified) + self.assertEqual( + user.registered_users.get(organization=self.default_org).is_verified, + False, + ) @capture_stderr() def test_create_phone_token_403(self): diff --git a/openwisp_radius/tests/test_api/test_rest_token.py b/openwisp_radius/tests/test_api/test_rest_token.py index 911d532f..e907d534 100644 --- a/openwisp_radius/tests/test_api/test_rest_token.py +++ b/openwisp_radius/tests/test_api/test_rest_token.py @@ -28,7 +28,7 @@ def _get_url(self): return reverse("radius:user_auth_token", args=[self.default_org.slug]) def _post_credentials(self): - with self.assertNumQueries(21): + with self.assertNumQueries(22): return self.client.post( self._get_url(), {"username": "tester", "password": "tester"} ) @@ -220,7 +220,9 @@ def test_unverified_registered_user_different_organization(self): response = self.client.post(url, user_cred) self.assertEqual(response.status_code, 403) - registered_user = RegisteredUser.objects.create(user=user, method="") + registered_user = RegisteredUser.objects.create( + user=user, organization=org2, method="" + ) with self.subTest("Test unverified user without registration method"): response = self.client.post(url, user_cred) self.assertEqual(response.status_code, 403) @@ -305,7 +307,7 @@ def _test_validate_auth_token_helper(self, user): self.assertEqual(response.data["response_code"], "BLANK_OR_INVALID_TOKEN") # valid token payload = dict(token=token.key) - with self.assertNumQueries(16): + with self.assertNumQueries(17): response = self.client.post(url, payload) self.assertEqual(response.status_code, 200) self.assertEqual( diff --git a/openwisp_radius/tests/test_batch_add_users.py b/openwisp_radius/tests/test_batch_add_users.py index c71fddc0..2a50a006 100644 --- a/openwisp_radius/tests/test_batch_add_users.py +++ b/openwisp_radius/tests/test_batch_add_users.py @@ -143,8 +143,9 @@ def test_verified_batch_user_creation(self): "CoovaChilli-Max-Total-Octets": 3000000000, }, ) - self.assertEqual(user.registered_user.is_verified, True) - self.assertEqual(user.registered_user.method, "manual") + reg_user = user.registered_users.get(organization=self.default_org) + self.assertEqual(reg_user.is_verified, True) + self.assertEqual(reg_user.method, "manual") class TestBatchAtomicity(FileMixin, BaseTransactionTestCase): diff --git a/openwisp_radius/tests/test_commands.py b/openwisp_radius/tests/test_commands.py index a72914ab..12de56e2 100644 --- a/openwisp_radius/tests/test_commands.py +++ b/openwisp_radius/tests/test_commands.py @@ -276,15 +276,19 @@ def _create_old_users(): self._call_command("batch_add_users", **options) User.objects.update(date_joined=now() - timedelta(days=3)) for user in User.objects.all(): - user.registered_user.is_verified = False - user.registered_user.method = "email" - user.registered_user.save(update_fields=["is_verified", "method"]) + reg_user = user.registered_users.first() + reg_user.is_verified = False + reg_user.method = "email" + reg_user.save(update_fields=["is_verified", "method"]) with self.subTest("Delete unverified users older than 2 days"): _create_old_users() # This user should not be deleted RegisteredUser.objects.create( - user=self._create_user(), method="mobile_phone", is_verified=False + user=self._create_user(), + organization=self.default_org, + method="mobile_phone", + is_verified=False, ) self.assertEqual(User.objects.count(), 4) @@ -298,6 +302,7 @@ def _create_old_users(): # This user should not be deleted RegisteredUser.objects.create( user=self._create_user(date_joined=now() - timedelta(days=3)), + organization=self.default_org, method="mobile_phone", is_verified=False, ) @@ -315,6 +320,7 @@ def _create_old_users(): # This user should not be deleted RegisteredUser.objects.create( user=self._create_user(date_joined=now() - timedelta(days=3)), + organization=self.default_org, method="email", is_verified=True, ) @@ -329,6 +335,7 @@ def _create_old_users(): user = self._create_user(date_joined=now() - timedelta(days=3)) RegisteredUser.objects.create( user=user, + organization=self.default_org, method="email", is_verified=False, ) @@ -353,6 +360,7 @@ def _create_old_users(): ) RegisteredUser.objects.create( user=user, + organization=self.default_org, method="email", is_verified=False, ) diff --git a/openwisp_radius/tests/test_saml/test_views.py b/openwisp_radius/tests/test_saml/test_views.py index 0c662970..adcaf1fd 100644 --- a/openwisp_radius/tests/test_saml/test_views.py +++ b/openwisp_radius/tests/test_saml/test_views.py @@ -152,8 +152,9 @@ def test_relay_state_relative_path(self): @capture_any_output() def test_user_registered_with_non_saml_method(self): + org = Organization.objects.get(slug="default") user = self._create_user(username="test-user", email="org_user@example.com") - RegisteredUser.objects.create(user=user, method="manual") + RegisteredUser.objects.create(user=user, method="manual", organization=org) relay_state = self._get_relay_state( redirect_url="https://captive-portal.example.com", org_slug="default" ) diff --git a/openwisp_radius/tests/test_selenium.py b/openwisp_radius/tests/test_selenium.py index 7b059345..8291f46d 100644 --- a/openwisp_radius/tests/test_selenium.py +++ b/openwisp_radius/tests/test_selenium.py @@ -21,6 +21,7 @@ @tag("selenium_tests") +@tag("no_parallel") class BasicTest( SeleniumTestMixin, FileMixin, StaticLiveServerTestCase, TestOrganizationMixin ): diff --git a/openwisp_radius/tests/test_social.py b/openwisp_radius/tests/test_social.py index 19ceafdb..86dc558e 100644 --- a/openwisp_radius/tests/test_social.py +++ b/openwisp_radius/tests/test_social.py @@ -14,6 +14,7 @@ from .mixins import ApiTokenMixin, BaseTestCase RadiusToken = load_model("openwisp_radius", "RadiusToken") +RegisteredUser = load_model("openwisp_radius", "RegisteredUser") OrganizationRadiusSettings = load_model("openwisp_radius", "OrganizationRadiusSettings") Organization = load_model("openwisp_users", "Organization") User = get_user_model() @@ -102,13 +103,13 @@ def test_redirect_cp_301(self): user = User.objects.filter(username="socialuser").first() self.assertTrue(user.is_member(self.default_org)) try: - reg_user = user.registered_user - except ObjectDoesNotExist: + reg_user = user.registered_users.get(organization=self.default_org) + except RegisteredUser.DoesNotExist: self.fail("RegisteredUser instance not found") self.assertEqual(reg_user.method, "social_login") # social login is not a legally valid identity verification method # so this should be always False when users sign up with this method - self.assertFalse(reg_user.is_verified) + self.assertEqual(reg_user.is_verified, False) def test_authorize_using_radius_user_token_200(self): self.test_redirect_cp_301() diff --git a/openwisp_radius/tests/test_tasks.py b/openwisp_radius/tests/test_tasks.py index 8aadb051..e99ae20f 100644 --- a/openwisp_radius/tests/test_tasks.py +++ b/openwisp_radius/tests/test_tasks.py @@ -139,9 +139,10 @@ def test_delete_unverified_users(self): management.call_command("batch_add_users", **options) User.objects.update(date_joined=now() - timedelta(days=3)) for user in User.objects.all(): - user.registered_user.is_verified = False - user.registered_user.method = "email" - user.registered_user.save(update_fields=["is_verified", "method"]) + reg_user = user.registered_users.first() + reg_user.is_verified = False + reg_user.method = "email" + reg_user.save(update_fields=["is_verified", "method"]) self.assertEqual(User.objects.count(), 3) tasks.delete_unverified_users.delay(older_than_days=2) self.assertEqual(User.objects.count(), 0) @@ -320,19 +321,35 @@ def test_unverify_inactive_users(self, *args): User.objects.exclude(id=active_user.id).update( last_login=today - timedelta(days=60) ) - RegisteredUser.objects.create(user=admin, is_verified=True) - RegisteredUser.objects.create(user=active_user, is_verified=True) RegisteredUser.objects.create( - user=unspecified_user, method="", is_verified=True + user=admin, organization=self.default_org, is_verified=True ) RegisteredUser.objects.create( - user=manually_registered_user, method="manual", is_verified=True + user=active_user, organization=self.default_org, is_verified=True ) RegisteredUser.objects.create( - user=email_registered_user, method="email", is_verified=True + user=unspecified_user, + organization=self.default_org, + method="", + is_verified=True, ) RegisteredUser.objects.create( - user=mobile_registered_user, method="mobile_phone", is_verified=True + user=manually_registered_user, + organization=self.default_org, + method="manual", + is_verified=True, + ) + RegisteredUser.objects.create( + user=email_registered_user, + organization=self.default_org, + method="email", + is_verified=True, + ) + RegisteredUser.objects.create( + user=mobile_registered_user, + organization=self.default_org, + method="mobile_phone", + is_verified=True, ) tasks.unverify_inactive_users.delay() @@ -342,12 +359,38 @@ def test_unverify_inactive_users(self, *args): manually_registered_user.refresh_from_db() email_registered_user.refresh_from_db() mobile_registered_user.refresh_from_db() - self.assertEqual(admin.registered_user.is_verified, True) - self.assertEqual(active_user.registered_user.is_verified, True) - self.assertEqual(unspecified_user.registered_user.is_verified, True) - self.assertEqual(manually_registered_user.registered_user.is_verified, True) - self.assertEqual(email_registered_user.registered_user.is_verified, True) - self.assertEqual(mobile_registered_user.registered_user.is_verified, False) + self.assertEqual( + admin.registered_users.get(organization=self.default_org).is_verified, + True, + ) + self.assertEqual( + active_user.registered_users.get(organization=self.default_org).is_verified, + True, + ) + self.assertEqual( + unspecified_user.registered_users.get( + organization=self.default_org + ).is_verified, + True, + ) + self.assertEqual( + manually_registered_user.registered_users.get( + organization=self.default_org + ).is_verified, + True, + ) + self.assertEqual( + email_registered_user.registered_users.get( + organization=self.default_org + ).is_verified, + True, + ) + self.assertEqual( + mobile_registered_user.registered_users.get( + organization=self.default_org + ).is_verified, + False, + ) @mock.patch.object(app_settings, "DELETE_INACTIVE_USERS", 30) def test_delete_inactive_users(self, *args): diff --git a/openwisp_radius/tests/test_token.py b/openwisp_radius/tests/test_token.py index 3a03115b..6e89a688 100644 --- a/openwisp_radius/tests/test_token.py +++ b/openwisp_radius/tests/test_token.py @@ -65,7 +65,10 @@ def _create_token( def test_is_already_verified(self): token = self._create_token() RegisteredUser.objects.create( - user=token.user, method="mobile_phone", is_verified=True + user=token.user, + organization=self.default_org, + method="mobile_phone", + is_verified=True, ) token.refresh_from_db() diff --git a/openwisp_radius/tests/test_users_integration.py b/openwisp_radius/tests/test_users_integration.py index dcefb721..d63bb5bc 100644 --- a/openwisp_radius/tests/test_users_integration.py +++ b/openwisp_radius/tests/test_users_integration.py @@ -96,9 +96,13 @@ def test_radiustoken_inline(self): @capture_stdout() def test_export_users_command(self): temp_file = NamedTemporaryFile(delete=False) - user = self._create_org_user().user + org_user = self._create_org_user() + user = org_user.user RegisteredUser.objects.create( - user=user, method="mobile_phone", is_verified=False + user=user, + organization=org_user.organization, + method="mobile_phone", + is_verified=False, ) with self.assertNumQueries(1): call_command("export_users", filename=temp_file.name) @@ -108,10 +112,10 @@ def test_export_users_command(self): csv_data = list(csv_reader) self.assertEqual(len(csv_data), 2) - self.assertIn("registered_user.method", csv_data[0]) - self.assertIn("registered_user.is_verified", csv_data[0]) - self.assertEqual(csv_data[1][-2], "mobile_phone") - self.assertEqual(csv_data[1][-1], "False") + # registered_user fields are no longer included in the export + # because RegisteredUser is now per-organization + self.assertNotIn("registered_user.method", csv_data[0]) + self.assertNotIn("registered_user.is_verified", csv_data[0]) def test_radiususergroup_inline(self): """ diff --git a/runtests b/runtests index 60761e1d..188b58c4 100755 --- a/runtests +++ b/runtests @@ -3,7 +3,7 @@ set -e # Standard tests coverage run runtests.py --parallel \ - --exclude-tag=no_parallel >/dev/null 2>&1 \ + --exclude-tag=no_parallel 2>&1 \ || ./runtests.py --exclude-tag=no_parallel # Test extensibility diff --git a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py new file mode 100644 index 00000000..18b5931c --- /dev/null +++ b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py @@ -0,0 +1,224 @@ +import uuid + +import django +import django.db.models.deletion +import swapper +from django.conf import settings +from django.db import connection, migrations, models + + +def get_swapped_model(apps, app_name, model_name): + model_path = swapper.get_model_name(app_name, model_name) + app, model = swapper.split(model_path) + return apps.get_model(app, model) + + +def recreate_table_forward(apps, schema_editor): + """ + Recreate registereduser table with new schema: + - UUID id as primary key + - user as ForeignKey (not primary key) + - organization as nullable ForeignKey + Then copy data from old table. + """ + RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") + db_table = RegisteredUser._meta.db_table + User = apps.get_model(settings.AUTH_USER_MODEL) + user_table = User._meta.db_table + + with connection.cursor() as cursor: + # Read existing data (sample_radius model has extra 'details' field) + cursor.execute( + f'SELECT "user_id", "is_verified", "method", "modified", "details" ' + f'FROM "{db_table}"' + ) + existing_data = cursor.fetchall() + + # Drop old table + cursor.execute(f'DROP TABLE IF EXISTS "{db_table}"') + + vendor = connection.vendor + if vendor == "sqlite": + cursor.execute( + f'CREATE TABLE "{db_table}" (' + f'"id" char(32) NOT NULL PRIMARY KEY, ' + f'"user_id" integer NOT NULL REFERENCES "{user_table}" ("id") ' + f"DEFERRABLE INITIALLY DEFERRED, " + f'"is_verified" bool NOT NULL, ' + f'"method" varchar(16) NOT NULL, ' + f'"modified" datetime NULL, ' + f'"details" varchar(64) NULL, ' + f'"organization_id" char(32) NULL REFERENCES ' + f'"openwisp_users_organization" ("id") ' + f"DEFERRABLE INITIALLY DEFERRED" + f")" + ) + else: + cursor.execute( + f'CREATE TABLE "{db_table}" (' + f'"id" uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), ' + f'"user_id" integer NOT NULL REFERENCES "{user_table}" ("id") ' + f"DEFERRABLE INITIALLY DEFERRED, " + f'"is_verified" boolean NOT NULL, ' + f'"method" varchar(16) NOT NULL, ' + f'"modified" timestamp with time zone NULL, ' + f'"details" varchar(64) NULL, ' + f'"organization_id" uuid NULL REFERENCES ' + f'"openwisp_users_organization" ("id") ' + f"DEFERRABLE INITIALLY DEFERRED" + f")" + ) + + # Create indexes + cursor.execute( + f'CREATE INDEX "{db_table}_user_id_idx" ON "{db_table}" ("user_id")' + ) + cursor.execute( + f'CREATE INDEX "{db_table}_org_id_idx" ON "{db_table}" ("organization_id")' + ) + + # Re-insert data (all as global records initially) + for user_id, is_verified, method, modified, details in existing_data: + new_id = uuid.uuid4().hex if vendor == "sqlite" else str(uuid.uuid4()) + cursor.execute( + f'INSERT INTO "{db_table}" ' + f'("id", "user_id", "is_verified", "method", "modified", ' + f'"details", "organization_id") VALUES (%s, %s, %s, %s, %s, %s, %s)', + [new_id, user_id, is_verified, method, modified, details, None], + ) + + +def migrate_registered_users_forward(apps, schema_editor): + """ + For each existing RegisteredUser (global), find all OrganizationUser + records for that user and create one RegisteredUser per organization. + """ + RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") + OrganizationUser = get_swapped_model(apps, "openwisp_users", "OrganizationUser") + + for reg_user in RegisteredUser.objects.filter(organization__isnull=True): + org_users = OrganizationUser.objects.filter(user_id=reg_user.user_id) + if org_users.exists(): + for org_user in org_users: + if not RegisteredUser.objects.filter( + user_id=reg_user.user_id, + organization_id=org_user.organization_id, + ).exists(): + RegisteredUser.objects.create( + id=uuid.uuid4(), + user_id=reg_user.user_id, + organization_id=org_user.organization_id, + is_verified=reg_user.is_verified, + method=reg_user.method, + ) + # Delete the original global record since we now have org-specific ones + reg_user.delete() + + +def migrate_registered_users_reverse(apps, schema_editor): + """ + Reverse migration: consolidate per-org records back to global. + """ + RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") + + user_ids = ( + RegisteredUser.objects.filter(organization__isnull=False) + .values_list("user_id", flat=True) + .distinct() + ) + for user_id in user_ids: + org_records = RegisteredUser.objects.filter( + user_id=user_id, organization__isnull=False + ).order_by("-is_verified", "method") + best = org_records.first() + if best: + global_exists = RegisteredUser.objects.filter( + user_id=user_id, organization__isnull=True + ).exists() + if not global_exists: + RegisteredUser.objects.create( + id=uuid.uuid4(), + user_id=user_id, + organization=None, + is_verified=best.is_verified, + method=best.method, + ) + org_records.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("0031_radiusbatch_status", "0042_set_existing_batches_completed"), + ] + + operations = [ + # Step 1: Recreate the table with new schema (UUID pk, ForeignKey user, organization) + migrations.SeparateDatabaseAndState( + database_operations=[ + migrations.RunPython( + recreate_table_forward, + migrations.RunPython.noop, + ), + ], + state_operations=[ + migrations.AddField( + model_name="registereduser", + name="id", + field=models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + migrations.AlterField( + model_name="registereduser", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="registered_users", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="registereduser", + name="organization", + field=models.ForeignKey( + blank=True, + help_text=( + "The organization this registration info belongs to. " + "If null, applies to all orgs without specific requirements." + ), + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="registered_users", + to="openwisp_users.organization", + verbose_name="organization", + ), + ), + ], + ), + # Step 2: Data migration - create per-org records + migrations.RunPython( + migrate_registered_users_forward, + migrate_registered_users_reverse, + ), + # Step 3: Add unique constraints + migrations.AddConstraint( + model_name="registereduser", + constraint=models.UniqueConstraint( + fields=["user", "organization"], + name="unique_registered_user_per_org", + ), + ), + migrations.AddConstraint( + model_name="registereduser", + constraint=models.UniqueConstraint( + condition=models.Q(("organization__isnull", True)), + fields=["user"], + name="unique_global_registered_user", + ), + ), + ] From c12902dda0be9285a7a447810fe44c32fdb827a3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 13 Apr 2026 20:18:00 +0530 Subject: [PATCH 02/27] [fix] Fixed migrations --- openwisp_radius/api/views.py | 2 +- openwisp_radius/base/models.py | 1 - .../0043_registereduser_add_uuid.py | 276 +++++++---------- .../0044_registered_user_multitenant_data.py | 31 ++ ...registered_user_multitenant_constraints.py | 25 ++ openwisp_radius/migrations/__init__.py | 205 +++++++++++++ openwisp_radius/settings.py | 11 +- openwisp_radius/tests/test_api/test_api.py | 5 +- .../tests/test_users_integration.py | 13 +- .../0032_registered_user_multitenant.py | 283 +++++++++--------- 10 files changed, 528 insertions(+), 324 deletions(-) create mode 100644 openwisp_radius/migrations/0044_registered_user_multitenant_data.py create mode 100644 openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 059015dd..0ab35da7 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -760,7 +760,7 @@ def post(self, request, *args, **kwargs): if not is_valid: return self._error_response(_("Invalid code.")) else: - reg_user, _ = RegisteredUser.get_or_create_for_user_and_org( + reg_user, __ = RegisteredUser.get_or_create_for_user_and_org( user=user, organization=self.organization, defaults={"is_verified": False, "method": ""}, diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index 81a54a4a..0da86c61 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1616,7 +1616,6 @@ class AbstractRegisteredUser(UUIDModel): on_delete=models.CASCADE, null=True, blank=True, - related_name="registered_users", verbose_name=_("organization"), help_text=( "The organization this registration info belongs to. " diff --git a/openwisp_radius/migrations/0043_registereduser_add_uuid.py b/openwisp_radius/migrations/0043_registereduser_add_uuid.py index 8b3879f3..5c8acc6b 100644 --- a/openwisp_radius/migrations/0043_registereduser_add_uuid.py +++ b/openwisp_radius/migrations/0043_registereduser_add_uuid.py @@ -2,166 +2,40 @@ import django import django.db.models.deletion +import django.utils.timezone +import model_utils.fields import swapper from django.conf import settings -from django.db import connection, migrations, models +from django.db import migrations, models +from openwisp_radius.registration import ( + REGISTRATION_METHOD_CHOICES, + get_registration_choices, +) -def get_swapped_model(apps, app_name, model_name): - model_path = swapper.get_model_name(app_name, model_name) - app, model = swapper.split(model_path) - return apps.get_model(app, model) +from . import ( + REGISTERED_USER_ORGANIZATION_HELP_TEXT, + copy_registered_users_ctcr_forward, + copy_registered_users_ctcr_reverse, +) -def recreate_table_forward(apps, schema_editor): - """ - Recreate registereduser table with new schema: - - UUID id as primary key - - user as ForeignKey (not primary key) - - organization as nullable ForeignKey - Then copy data from old table. - """ - RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") - db_table = RegisteredUser._meta.db_table - User = apps.get_model(settings.AUTH_USER_MODEL) - user_table = User._meta.db_table +def copy_registered_users_forward(apps, schema_editor): + copy_registered_users_ctcr_forward(apps, schema_editor, app_label="openwisp_radius") - with connection.cursor() as cursor: - # Read existing data (openwisp_radius model has extra 'details' field) - cursor.execute( - f'SELECT "user_id", "is_verified", "method", "modified", "details" ' - f'FROM "{db_table}"' - ) - existing_data = cursor.fetchall() - # Drop old table - cursor.execute(f'DROP TABLE IF EXISTS "{db_table}"') - - vendor = connection.vendor - if vendor == "sqlite": - cursor.execute( - f'CREATE TABLE "{db_table}" (' - f'"id" char(32) NOT NULL PRIMARY KEY, ' - f'"user_id" integer NOT NULL REFERENCES "{user_table}" ("id") ' - f"DEFERRABLE INITIALLY DEFERRED, " - f'"is_verified" bool NOT NULL, ' - f'"method" varchar(16) NOT NULL, ' - f'"modified" datetime NULL, ' - f'"details" varchar(64) NULL, ' - f'"organization_id" char(32) NULL REFERENCES ' - f'"openwisp_users_organization" ("id") ' - f"DEFERRABLE INITIALLY DEFERRED" - f")" - ) - else: - cursor.execute( - f'CREATE TABLE "{db_table}" (' - f'"id" uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), ' - f'"user_id" integer NOT NULL REFERENCES "{user_table}" ("id") ' - f"DEFERRABLE INITIALLY DEFERRED, " - f'"is_verified" boolean NOT NULL, ' - f'"method" varchar(16) NOT NULL, ' - f'"modified" timestamp with time zone NULL, ' - f'"details" varchar(64) NULL, ' - f'"organization_id" uuid NULL REFERENCES ' - f'"openwisp_users_organization" ("id") ' - f"DEFERRABLE INITIALLY DEFERRED" - f")" - ) - - # Create indexes - cursor.execute( - f'CREATE INDEX "{db_table}_user_id_idx" ON "{db_table}" ("user_id")' - ) - cursor.execute( - f'CREATE INDEX "{db_table}_org_id_idx" ON "{db_table}" ("organization_id")' - ) - - # Re-insert data (all as global records initially) - for user_id, is_verified, method, modified, details in existing_data: - new_id = uuid.uuid4().hex if vendor == "sqlite" else str(uuid.uuid4()) - cursor.execute( - f'INSERT INTO "{db_table}" ' - f'("id", "user_id", "is_verified", "method", "modified", ' - f'"details", "organization_id") VALUES (%s, %s, %s, %s, %s, %s, %s)', - [new_id, user_id, is_verified, method, modified, details, None], - ) - - -def migrate_registered_users_forward(apps, schema_editor): - """ - For each existing RegisteredUser (global), find all OrganizationUser - records for that user and create one RegisteredUser per organization. - """ - RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") - OrganizationUser = get_swapped_model(apps, "openwisp_users", "OrganizationUser") - - for reg_user in RegisteredUser.objects.filter(organization__isnull=True): - org_users = OrganizationUser.objects.filter(user_id=reg_user.user_id) - if org_users.exists(): - for org_user in org_users: - if not RegisteredUser.objects.filter( - user_id=reg_user.user_id, - organization_id=org_user.organization_id, - ).exists(): - RegisteredUser.objects.create( - id=uuid.uuid4(), - user_id=reg_user.user_id, - organization_id=org_user.organization_id, - is_verified=reg_user.is_verified, - method=reg_user.method, - ) - # Delete the original global record since we now have org-specific ones - reg_user.delete() - - -def migrate_registered_users_reverse(apps, schema_editor): - """ - Reverse migration: consolidate per-org records back to global. - """ - RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") - - user_ids = ( - RegisteredUser.objects.filter(organization__isnull=False) - .values_list("user_id", flat=True) - .distinct() - ) - for user_id in user_ids: - org_records = RegisteredUser.objects.filter( - user_id=user_id, organization__isnull=False - ).order_by("-is_verified", "method") - best = org_records.first() - if best: - global_exists = RegisteredUser.objects.filter( - user_id=user_id, organization__isnull=True - ).exists() - if not global_exists: - RegisteredUser.objects.create( - id=uuid.uuid4(), - user_id=user_id, - organization=None, - is_verified=best.is_verified, - method=best.method, - ) - org_records.delete() +def copy_registered_users_reverse(apps, schema_editor): + copy_registered_users_ctcr_reverse(apps, schema_editor, app_label="openwisp_radius") class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("openwisp_radius", "0042_set_existing_batches_completed"), ] operations = [ - # Step 1: Recreate the table with new schema (UUID pk, ForeignKey user, organization) migrations.SeparateDatabaseAndState( - database_operations=[ - migrations.RunPython( - recreate_table_forward, - migrations.RunPython.noop, - ), - ], state_operations=[ migrations.AddField( model_name="registereduser", @@ -187,38 +61,104 @@ class Migration(migrations.Migration): name="organization", field=models.ForeignKey( blank=True, - help_text=( - "The organization this registration info belongs to. " - "If null, applies to all orgs without specific requirements." - ), + help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="registered_users", - to="openwisp_users.organization", + to=swapper.get_model_name("openwisp_users", "Organization"), verbose_name="organization", ), ), ], - ), - # Step 2: Data migration - create per-org records - migrations.RunPython( - migrate_registered_users_forward, - migrate_registered_users_reverse, - ), - # Step 3: Add unique constraints - migrations.AddConstraint( - model_name="registereduser", - constraint=models.UniqueConstraint( - fields=["user", "organization"], - name="unique_registered_user_per_org", - ), - ), - migrations.AddConstraint( - model_name="registereduser", - constraint=models.UniqueConstraint( - condition=models.Q(("organization__isnull", True)), - fields=["user"], - name="unique_global_registered_user", - ), + database_operations=[ + migrations.CreateModel( + name="RegisteredUserNew", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "method", + models.CharField( + blank=True, + choices=( + REGISTRATION_METHOD_CHOICES + if django.VERSION < (5, 0) + else get_registration_choices + ), + default="", + help_text=( + "users can sign up in different ways, some " + "methods are valid as indirect identity " + "verification (eg: mobile phone SIM card in " + "most countries)" + ), + max_length=64, + verbose_name="registration method", + ), + ), + ( + "is_verified", + models.BooleanField( + default=False, + help_text=( + "whether the user has completed any identity " + "verification process sucessfully" + ), + verbose_name="verified", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="Last verification change", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organization", + models.ForeignKey( + blank=True, + help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=swapper.get_model_name( + "openwisp_users", "Organization" + ), + verbose_name="organization", + ), + ), + ], + options={ + "verbose_name": "Registration Information", + "verbose_name_plural": "Registration Information", + }, + ), + migrations.RunPython( + copy_registered_users_forward, + copy_registered_users_reverse, + ), + migrations.DeleteModel(name="RegisteredUser"), + migrations.RenameModel( + old_name="RegisteredUserNew", + new_name="RegisteredUser", + ), + ], ), ] diff --git a/openwisp_radius/migrations/0044_registered_user_multitenant_data.py b/openwisp_radius/migrations/0044_registered_user_multitenant_data.py new file mode 100644 index 00000000..da104a51 --- /dev/null +++ b/openwisp_radius/migrations/0044_registered_user_multitenant_data.py @@ -0,0 +1,31 @@ +from django.db import migrations + +from . import ( + migrate_registered_users_multitenant_forward, + migrate_registered_users_multitenant_reverse, +) + + +def migrate_registered_users_forward(apps, schema_editor): + migrate_registered_users_multitenant_forward( + apps, schema_editor, app_label="openwisp_radius" + ) + + +def migrate_registered_users_reverse(apps, schema_editor): + migrate_registered_users_multitenant_reverse( + apps, schema_editor, app_label="openwisp_radius" + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("openwisp_radius", "0043_registereduser_add_uuid"), + ] + + operations = [ + migrations.RunPython( + migrate_registered_users_forward, + migrate_registered_users_reverse, + ), + ] diff --git a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py new file mode 100644 index 00000000..af8ad357 --- /dev/null +++ b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py @@ -0,0 +1,25 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("openwisp_radius", "0044_registered_user_multitenant_data"), + ] + + operations = [ + migrations.AddConstraint( + model_name="registereduser", + constraint=models.UniqueConstraint( + fields=["user", "organization"], + name="unique_registered_user_per_org", + ), + ), + migrations.AddConstraint( + model_name="registereduser", + constraint=models.UniqueConstraint( + fields=["user"], + condition=models.Q(organization__isnull=True), + name="unique_global_registered_user", + ), + ), + ] diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index 45c9abf2..af874af1 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -1,4 +1,5 @@ import uuid +from collections import defaultdict import swapper from django.conf import settings @@ -7,6 +8,12 @@ from ..utils import create_default_groups +BATCH_SIZE = 1000 +REGISTERED_USER_ORGANIZATION_HELP_TEXT = ( + "The organization this registration info belongs to. " + "If null, applies to all orgs without specific requirements." +) + def get_swapped_model(apps, app_name, model_name): model_path = swapper.get_model_name(app_name, model_name) @@ -14,6 +21,204 @@ def get_swapped_model(apps, app_name, model_name): return apps.get_model(app, model) +def _batched_iterator(iterator, batch_size=BATCH_SIZE): + batch = [] + for item in iterator: + batch.append(item) + if len(batch) >= batch_size: + yield batch + batch = [] + if batch: + yield batch + + +def _flush_bulk_create(model, objects, batch_size=BATCH_SIZE): + if objects: + model.objects.bulk_create(objects, batch_size=batch_size) + objects.clear() + + +def _registered_user_extra_kwargs(registered_user, extra_fields=()): + return { + field_name: getattr(registered_user, field_name) for field_name in extra_fields + } + + +def copy_registered_users_ctcr_forward( + apps, + schema_editor, + app_label, + new_model_name="RegisteredUserNew", + extra_fields=(), +): + RegisteredUser = apps.get_model(app_label, "RegisteredUser") + RegisteredUserNew = apps.get_model(app_label, new_model_name) + if RegisteredUser._meta.swapped: + return + + new_objects = [] + queryset = RegisteredUser.objects.order_by("user_id") + for registered_user in queryset.iterator(chunk_size=BATCH_SIZE): + copied = RegisteredUserNew( + id=uuid.uuid4(), + user_id=registered_user.user_id, + organization=None, + method=registered_user.method, + is_verified=registered_user.is_verified, + **_registered_user_extra_kwargs(registered_user, extra_fields), + ) + copied.modified = registered_user.modified + new_objects.append(copied) + if len(new_objects) >= BATCH_SIZE: + _flush_bulk_create(RegisteredUserNew, new_objects) + _flush_bulk_create(RegisteredUserNew, new_objects) + + +def copy_registered_users_ctcr_reverse( + apps, + schema_editor, + app_label, + new_model_name="RegisteredUserNew", + extra_fields=(), +): + RegisteredUser = apps.get_model(app_label, "RegisteredUser") + RegisteredUserNew = apps.get_model(app_label, new_model_name) + if RegisteredUser._meta.swapped: + return + + restored_objects = [] + previous_user_id = None + queryset = RegisteredUserNew.objects.order_by( + "user_id", "-is_verified", "method", "pk" + ) + for registered_user in queryset.iterator(chunk_size=BATCH_SIZE): + if registered_user.user_id == previous_user_id: + continue + previous_user_id = registered_user.user_id + restored = RegisteredUser( + user_id=registered_user.user_id, + method=registered_user.method, + is_verified=registered_user.is_verified, + **_registered_user_extra_kwargs(registered_user, extra_fields), + ) + restored.modified = registered_user.modified + restored_objects.append(restored) + if len(restored_objects) >= BATCH_SIZE: + _flush_bulk_create(RegisteredUser, restored_objects) + _flush_bulk_create(RegisteredUser, restored_objects) + + +def migrate_registered_users_multitenant_forward( + apps, schema_editor, app_label, extra_fields=() +): + RegisteredUser = apps.get_model(app_label, "RegisteredUser") + if RegisteredUser._meta.swapped: + return + OrganizationUser = get_swapped_model(apps, "openwisp_users", "OrganizationUser") + + queryset = RegisteredUser.objects.filter(organization__isnull=True).order_by( + "user_id" + ) + iterator = queryset.iterator(chunk_size=BATCH_SIZE) + for batch in _batched_iterator(iterator, BATCH_SIZE): + user_ids = [registered_user.user_id for registered_user in batch] + memberships = defaultdict(set) + membership_qs = OrganizationUser.objects.filter( + user_id__in=user_ids + ).values_list("user_id", "organization_id") + for user_id, organization_id in membership_qs.iterator(chunk_size=BATCH_SIZE): + memberships[user_id].add(organization_id) + + existing_pairs = set( + RegisteredUser.objects.filter( + user_id__in=user_ids, + organization__isnull=False, + ).values_list("user_id", "organization_id") + ) + + to_create = [] + to_delete_pks = [] + for registered_user in batch: + organization_ids = sorted(memberships.get(registered_user.user_id, ())) + if not organization_ids: + continue + to_delete_pks.append(registered_user.pk) + extra_kwargs = _registered_user_extra_kwargs(registered_user, extra_fields) + for organization_id in organization_ids: + pair = (registered_user.user_id, organization_id) + if pair in existing_pairs: + continue + existing_pairs.add(pair) + copied = RegisteredUser( + id=uuid.uuid4(), + user_id=registered_user.user_id, + organization_id=organization_id, + is_verified=registered_user.is_verified, + method=registered_user.method, + **extra_kwargs, + ) + copied.modified = registered_user.modified + to_create.append(copied) + + _flush_bulk_create(RegisteredUser, to_create) + if to_delete_pks: + RegisteredUser.objects.filter(pk__in=to_delete_pks).delete() + + +def migrate_registered_users_multitenant_reverse( + apps, schema_editor, app_label, extra_fields=() +): + RegisteredUser = apps.get_model(app_label, "RegisteredUser") + if RegisteredUser._meta.swapped: + return + + user_ids_qs = ( + RegisteredUser.objects.filter(organization__isnull=False) + .order_by() + .values_list("user_id", flat=True) + .distinct() + ) + for user_id_batch in _batched_iterator( + user_ids_qs.iterator(chunk_size=BATCH_SIZE), BATCH_SIZE + ): + existing_globals = set( + RegisteredUser.objects.filter( + user_id__in=user_id_batch, + organization__isnull=True, + ).values_list("user_id", flat=True) + ) + org_records = RegisteredUser.objects.filter( + user_id__in=user_id_batch, + organization__isnull=False, + ).order_by("user_id", "-is_verified", "method", "pk") + + to_create = [] + to_delete_pks = [] + current_user_id = None + + for registered_user in org_records.iterator(chunk_size=BATCH_SIZE): + to_delete_pks.append(registered_user.pk) + if registered_user.user_id == current_user_id: + continue + current_user_id = registered_user.user_id + if registered_user.user_id in existing_globals: + continue + restored = RegisteredUser( + id=uuid.uuid4(), + user_id=registered_user.user_id, + organization=None, + is_verified=registered_user.is_verified, + method=registered_user.method, + **_registered_user_extra_kwargs(registered_user, extra_fields), + ) + restored.modified = registered_user.modified + to_create.append(restored) + + _flush_bulk_create(RegisteredUser, to_create) + if to_delete_pks: + RegisteredUser.objects.filter(pk__in=to_delete_pks).delete() + + def delete_old_radius_token(apps, schema_editor): RadiusToken = get_swapped_model(apps, "openwisp_radius", "RadiusToken") RadiusToken.objects.all().delete() diff --git a/openwisp_radius/settings.py b/openwisp_radius/settings.py index e7f908dd..dea0d461 100644 --- a/openwisp_radius/settings.py +++ b/openwisp_radius/settings.py @@ -232,10 +232,13 @@ def get_default_password_reset_url(urls): if not hasattr(settings, "OPENWISP_USERS_EXPORT_USERS_COMMAND_CONFIG"): from openwisp_users import settings as ow_users_settings - ow_users_settings.EXPORT_USERS_COMMAND_CONFIG["fields"].extend( - ["registered_user.method", "registered_user.is_verified"] + ow_users_settings.EXPORT_USERS_COMMAND_CONFIG["fields"].append( + { + "name": "registered_users", + "fields": ("organization_id", "method", "is_verified"), + } ) - ow_users_settings.EXPORT_USERS_COMMAND_CONFIG["select_related"].extend( - ["registered_user"] + ow_users_settings.EXPORT_USERS_COMMAND_CONFIG["prefetch_related"].extend( + ["registered_users"] ) BATCH_ASYNC_THRESHOLD = get_settings_value("BATCH_ASYNC_THRESHOLD", 15) diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 751b41dc..1a271539 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -336,7 +336,10 @@ def test_radius_user_serializer(self): self.fail(f"user not found: {e}") with self.assertNumQueries(0): - data = RadiusUserSerializer(user).data + # Organization is required to get the RegisteredUser object + view = mock.MagicMock() + view.organization = self.default_org + data = RadiusUserSerializer(user, context={"view": view}).data with self.subTest("test full data"): self.assertEqual( diff --git a/openwisp_radius/tests/test_users_integration.py b/openwisp_radius/tests/test_users_integration.py index d63bb5bc..74731c39 100644 --- a/openwisp_radius/tests/test_users_integration.py +++ b/openwisp_radius/tests/test_users_integration.py @@ -98,13 +98,13 @@ def test_export_users_command(self): temp_file = NamedTemporaryFile(delete=False) org_user = self._create_org_user() user = org_user.user - RegisteredUser.objects.create( + reg_user = RegisteredUser.objects.create( user=user, organization=org_user.organization, method="mobile_phone", is_verified=False, ) - with self.assertNumQueries(1): + with self.assertNumQueries(2): call_command("export_users", filename=temp_file.name) with open(temp_file.name, "r") as file: @@ -112,10 +112,11 @@ def test_export_users_command(self): csv_data = list(csv_reader) self.assertEqual(len(csv_data), 2) - # registered_user fields are no longer included in the export - # because RegisteredUser is now per-organization - self.assertNotIn("registered_user.method", csv_data[0]) - self.assertNotIn("registered_user.is_verified", csv_data[0]) + self.assertIn("registered_users", csv_data[0]) + self.assertEqual( + csv_data[1][-1], + f"(({reg_user.organization_id},{reg_user.method},{reg_user.is_verified}))", + ) def test_radiususergroup_inline(self): """ diff --git a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py index 18b5931c..558972d8 100644 --- a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py +++ b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py @@ -2,164 +2,166 @@ import django import django.db.models.deletion +import django.utils.timezone +import model_utils.fields import swapper from django.conf import settings -from django.db import connection, migrations, models - - -def get_swapped_model(apps, app_name, model_name): - model_path = swapper.get_model_name(app_name, model_name) - app, model = swapper.split(model_path) - return apps.get_model(app, model) - - -def recreate_table_forward(apps, schema_editor): - """ - Recreate registereduser table with new schema: - - UUID id as primary key - - user as ForeignKey (not primary key) - - organization as nullable ForeignKey - Then copy data from old table. - """ - RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") - db_table = RegisteredUser._meta.db_table - User = apps.get_model(settings.AUTH_USER_MODEL) - user_table = User._meta.db_table - - with connection.cursor() as cursor: - # Read existing data (sample_radius model has extra 'details' field) - cursor.execute( - f'SELECT "user_id", "is_verified", "method", "modified", "details" ' - f'FROM "{db_table}"' - ) - existing_data = cursor.fetchall() - - # Drop old table - cursor.execute(f'DROP TABLE IF EXISTS "{db_table}"') - - vendor = connection.vendor - if vendor == "sqlite": - cursor.execute( - f'CREATE TABLE "{db_table}" (' - f'"id" char(32) NOT NULL PRIMARY KEY, ' - f'"user_id" integer NOT NULL REFERENCES "{user_table}" ("id") ' - f"DEFERRABLE INITIALLY DEFERRED, " - f'"is_verified" bool NOT NULL, ' - f'"method" varchar(16) NOT NULL, ' - f'"modified" datetime NULL, ' - f'"details" varchar(64) NULL, ' - f'"organization_id" char(32) NULL REFERENCES ' - f'"openwisp_users_organization" ("id") ' - f"DEFERRABLE INITIALLY DEFERRED" - f")" - ) - else: - cursor.execute( - f'CREATE TABLE "{db_table}" (' - f'"id" uuid NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(), ' - f'"user_id" integer NOT NULL REFERENCES "{user_table}" ("id") ' - f"DEFERRABLE INITIALLY DEFERRED, " - f'"is_verified" boolean NOT NULL, ' - f'"method" varchar(16) NOT NULL, ' - f'"modified" timestamp with time zone NULL, ' - f'"details" varchar(64) NULL, ' - f'"organization_id" uuid NULL REFERENCES ' - f'"openwisp_users_organization" ("id") ' - f"DEFERRABLE INITIALLY DEFERRED" - f")" - ) +from django.db import migrations, models + +from openwisp_radius.migrations import ( + REGISTERED_USER_ORGANIZATION_HELP_TEXT, + copy_registered_users_ctcr_forward, + copy_registered_users_ctcr_reverse, + migrate_registered_users_multitenant_forward, + migrate_registered_users_multitenant_reverse, +) +from openwisp_radius.registration import ( + REGISTRATION_METHOD_CHOICES, + get_registration_choices, +) + + +def copy_registered_users_forward(apps, schema_editor): + copy_registered_users_ctcr_forward( + apps, + schema_editor, + app_label="sample_radius", + extra_fields=("details",), + ) - # Create indexes - cursor.execute( - f'CREATE INDEX "{db_table}_user_id_idx" ON "{db_table}" ("user_id")' - ) - cursor.execute( - f'CREATE INDEX "{db_table}_org_id_idx" ON "{db_table}" ("organization_id")' - ) - # Re-insert data (all as global records initially) - for user_id, is_verified, method, modified, details in existing_data: - new_id = uuid.uuid4().hex if vendor == "sqlite" else str(uuid.uuid4()) - cursor.execute( - f'INSERT INTO "{db_table}" ' - f'("id", "user_id", "is_verified", "method", "modified", ' - f'"details", "organization_id") VALUES (%s, %s, %s, %s, %s, %s, %s)', - [new_id, user_id, is_verified, method, modified, details, None], - ) +def copy_registered_users_reverse(apps, schema_editor): + copy_registered_users_ctcr_reverse( + apps, + schema_editor, + app_label="sample_radius", + extra_fields=("details",), + ) def migrate_registered_users_forward(apps, schema_editor): - """ - For each existing RegisteredUser (global), find all OrganizationUser - records for that user and create one RegisteredUser per organization. - """ - RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") - OrganizationUser = get_swapped_model(apps, "openwisp_users", "OrganizationUser") - - for reg_user in RegisteredUser.objects.filter(organization__isnull=True): - org_users = OrganizationUser.objects.filter(user_id=reg_user.user_id) - if org_users.exists(): - for org_user in org_users: - if not RegisteredUser.objects.filter( - user_id=reg_user.user_id, - organization_id=org_user.organization_id, - ).exists(): - RegisteredUser.objects.create( - id=uuid.uuid4(), - user_id=reg_user.user_id, - organization_id=org_user.organization_id, - is_verified=reg_user.is_verified, - method=reg_user.method, - ) - # Delete the original global record since we now have org-specific ones - reg_user.delete() + migrate_registered_users_multitenant_forward( + apps, + schema_editor, + app_label="sample_radius", + extra_fields=("details",), + ) def migrate_registered_users_reverse(apps, schema_editor): - """ - Reverse migration: consolidate per-org records back to global. - """ - RegisteredUser = get_swapped_model(apps, "openwisp_radius", "RegisteredUser") - - user_ids = ( - RegisteredUser.objects.filter(organization__isnull=False) - .values_list("user_id", flat=True) - .distinct() + migrate_registered_users_multitenant_reverse( + apps, + schema_editor, + app_label="sample_radius", + extra_fields=("details",), ) - for user_id in user_ids: - org_records = RegisteredUser.objects.filter( - user_id=user_id, organization__isnull=False - ).order_by("-is_verified", "method") - best = org_records.first() - if best: - global_exists = RegisteredUser.objects.filter( - user_id=user_id, organization__isnull=True - ).exists() - if not global_exists: - RegisteredUser.objects.create( - id=uuid.uuid4(), - user_id=user_id, - organization=None, - is_verified=best.is_verified, - method=best.method, - ) - org_records.delete() class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("0031_radiusbatch_status", "0042_set_existing_batches_completed"), + ("sample_radius", "0031_radiusbatch_status"), ] operations = [ - # Step 1: Recreate the table with new schema (UUID pk, ForeignKey user, organization) migrations.SeparateDatabaseAndState( database_operations=[ + migrations.CreateModel( + name="RegisteredUserNew", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "details", + models.CharField( + blank=True, + max_length=64, + null=True, + ), + ), + ( + "method", + models.CharField( + blank=True, + choices=( + REGISTRATION_METHOD_CHOICES + if django.VERSION < (5, 0) + else get_registration_choices + ), + default="", + help_text=( + "users can sign up in different ways, some " + "methods are valid as indirect identity " + "verification (eg: mobile phone SIM card in " + "most countries)" + ), + max_length=64, + verbose_name="registration method", + ), + ), + ( + "is_verified", + models.BooleanField( + default=False, + help_text=( + "whether the user has completed any identity " + "verification process sucessfully" + ), + verbose_name="verified", + ), + ), + ( + "modified", + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name="Last verification change", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organization", + models.ForeignKey( + blank=True, + help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=swapper.get_model_name( + "openwisp_users", "Organization" + ), + verbose_name="organization", + ), + ), + ], + options={ + "verbose_name": "Registration Information", + "verbose_name_plural": "Registration Information", + }, + ), migrations.RunPython( - recreate_table_forward, - migrations.RunPython.noop, + copy_registered_users_forward, + copy_registered_users_reverse, + ), + migrations.DeleteModel(name="RegisteredUser"), + migrations.RenameModel( + old_name="RegisteredUserNew", + new_name="RegisteredUser", ), ], state_operations=[ @@ -187,25 +189,20 @@ class Migration(migrations.Migration): name="organization", field=models.ForeignKey( blank=True, - help_text=( - "The organization this registration info belongs to. " - "If null, applies to all orgs without specific requirements." - ), + help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, null=True, on_delete=django.db.models.deletion.CASCADE, related_name="registered_users", - to="openwisp_users.organization", + to=swapper.get_model_name("openwisp_users", "Organization"), verbose_name="organization", ), ), ], ), - # Step 2: Data migration - create per-org records migrations.RunPython( migrate_registered_users_forward, migrate_registered_users_reverse, ), - # Step 3: Add unique constraints migrations.AddConstraint( model_name="registereduser", constraint=models.UniqueConstraint( @@ -216,8 +213,8 @@ class Migration(migrations.Migration): migrations.AddConstraint( model_name="registereduser", constraint=models.UniqueConstraint( - condition=models.Q(("organization__isnull", True)), fields=["user"], + condition=models.Q(organization__isnull=True), name="unique_global_registered_user", ), ), From 13d559cdb6841b6a0484597bcc7eb892a8c5871b Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Apr 2026 01:17:06 +0530 Subject: [PATCH 03/27] [qa] Fixed QA issues --- openwisp_radius/base/models.py | 3 +-- openwisp_radius/social/views.py | 2 +- openwisp_radius/tests/test_social.py | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index 0da86c61..d73a337b 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -4,7 +4,6 @@ import logging import os import string -import uuid from datetime import timedelta from io import StringIO @@ -16,7 +15,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.cache import cache -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ValidationError from django.core.mail import send_mail from django.db import models, transaction from django.db.models import ProtectedError, Q diff --git a/openwisp_radius/social/views.py b/openwisp_radius/social/views.py index 5491cdf6..ac132611 100644 --- a/openwisp_radius/social/views.py +++ b/openwisp_radius/social/views.py @@ -1,5 +1,5 @@ import swapper -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.core.exceptions import PermissionDenied from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ diff --git a/openwisp_radius/tests/test_social.py b/openwisp_radius/tests/test_social.py index 86dc558e..07454792 100644 --- a/openwisp_radius/tests/test_social.py +++ b/openwisp_radius/tests/test_social.py @@ -2,7 +2,6 @@ from allauth.socialaccount.models import SocialAccount from django.contrib.auth import get_user_model -from django.core.exceptions import ObjectDoesNotExist from django.urls import reverse from rest_framework.authtoken.models import Token from swapper import load_model From 7eb659445679078b5524a038c1b31f830ed35330 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Apr 2026 01:17:23 +0530 Subject: [PATCH 04/27] [ci] Upgraded openwisp-users --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 339692e1..f6aeddd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,7 @@ jobs: pip install -U pip wheel setuptools pip install -U -r requirements-test.txt pip install -e .[saml,openvpn_status] + pip install --upgrade --no-deps --no-cache-dir "https://github.com/openwisp/openwisp-users/tarball/issues/497-export-users" pip install ${{ matrix.django-version }} - name: Start InfluxDB and Redis container From 715740987beb0cf692e69eb3a15562b4aacb92a9 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Apr 2026 22:45:00 +0530 Subject: [PATCH 05/27] [ci] Fixed failures --- .github/workflows/ci.yml | 2 +- openwisp_radius/migrations/0043_registereduser_add_uuid.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6aeddd1..d6aeb276 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,7 @@ jobs: pip install -U pip wheel setuptools pip install -U -r requirements-test.txt pip install -e .[saml,openvpn_status] - pip install --upgrade --no-deps --no-cache-dir "https://github.com/openwisp/openwisp-users/tarball/issues/497-export-users" + pip install --upgrade --no-deps --no-cache-dir --force-reinstall "https://github.com/openwisp/openwisp-users/tarball/issues/497-export-users" pip install ${{ matrix.django-version }} - name: Start InfluxDB and Redis container diff --git a/openwisp_radius/migrations/0043_registereduser_add_uuid.py b/openwisp_radius/migrations/0043_registereduser_add_uuid.py index 5c8acc6b..c85a67ac 100644 --- a/openwisp_radius/migrations/0043_registereduser_add_uuid.py +++ b/openwisp_radius/migrations/0043_registereduser_add_uuid.py @@ -64,7 +64,6 @@ class Migration(migrations.Migration): help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, null=True, on_delete=django.db.models.deletion.CASCADE, - related_name="registered_users", to=swapper.get_model_name("openwisp_users", "Organization"), verbose_name="organization", ), From 3b4c451ab79bf5dd0733c746a5bf996174b7125d Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Apr 2026 23:56:22 +0530 Subject: [PATCH 06/27] [fix] Fixed migrations for sample app --- .../sample_radius/migrations/0032_registered_user_multitenant.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py index 558972d8..56fd8aac 100644 --- a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py +++ b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py @@ -192,7 +192,6 @@ class Migration(migrations.Migration): help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, null=True, on_delete=django.db.models.deletion.CASCADE, - related_name="registered_users", to=swapper.get_model_name("openwisp_users", "Organization"), verbose_name="organization", ), From e478092568eecb7f9fe81a5303cabb52da59c430 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 15 Apr 2026 20:20:58 +0530 Subject: [PATCH 07/27] [fix] Fixes by @coderabbitai --- openwisp_radius/admin.py | 4 +- openwisp_radius/api/serializers.py | 31 ++++++++----- openwisp_radius/api/utils.py | 3 ++ openwisp_radius/api/views.py | 14 +++--- openwisp_radius/base/models.py | 43 +++++++++++-------- .../integrations/monitoring/tasks.py | 1 + .../commands/base/delete_unverified_users.py | 22 +++++++--- .../0043_registereduser_add_uuid.py | 2 + openwisp_radius/migrations/__init__.py | 35 ++++++++++++--- openwisp_radius/tests/test_api/test_api.py | 5 ++- .../tests/test_api/test_rest_token.py | 2 +- openwisp_radius/tests/test_batch_add_users.py | 2 +- openwisp_radius/tests/test_commands.py | 33 +++++++++++++- openwisp_radius/tests/test_tasks.py | 5 +-- .../0032_registered_user_multitenant.py | 2 + 15 files changed, 148 insertions(+), 56 deletions(-) diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 9f71e398..6880a23c 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -3,7 +3,7 @@ from django import forms from django.conf import settings from django.contrib import admin, messages -from django.contrib.admin import ModelAdmin, StackedInline, TabularInline +from django.contrib.admin import ModelAdmin, StackedInline from django.contrib.admin.utils import model_ngettext from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied @@ -534,7 +534,7 @@ def has_change_permission(self, request, obj=None): return False -class RegisteredUserInline(TabularInline): +class RegisteredUserInline(StackedInline): model = RegisteredUser form = AlwaysHasChangedForm extra = 0 diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 4c62c812..b099ecc7 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -793,17 +793,26 @@ class Meta: ] def _get_registered_user(self, obj): - view = self.context.get("view") - organization = getattr(view, "organization", None) - org_reg_user = None - global_reg_user = None - for ru in obj.registered_users.all(): - if organization and ru.organization_id == organization.pk: - org_reg_user = ru - break - elif ru.organization_id is None: - global_reg_user = ru - return org_reg_user or global_reg_user + if not hasattr(self, "_registered_user_cache"): + self._registered_user_cache = {} + if obj.pk not in self._registered_user_cache: + view = self.context.get("view") + organization = getattr(view, "organization", None) + org_reg_user = None + global_reg_user = None + # We iterate over .all() instead of using .filter() because callers + # of this serializer (e.g. validate_auth_token) prefetch + # "registered_users" via prefetch_related. Using .all() hits the + # in-memory prefetch cache (0 DB queries), whereas .filter() would + # bypass the cache and issue a new query every time. + for ru in obj.registered_users.all(): + if organization and ru.organization_id == organization.pk: + org_reg_user = ru + break + elif ru.organization_id is None: + global_reg_user = ru + self._registered_user_cache[obj.pk] = org_reg_user or global_reg_user + return self._registered_user_cache[obj.pk] def get_is_verified(self, obj): reg_user = self._get_registered_user(obj) diff --git a/openwisp_radius/api/utils.py b/openwisp_radius/api/utils.py index 94ed98a9..aaa4f9f6 100644 --- a/openwisp_radius/api/utils.py +++ b/openwisp_radius/api/utils.py @@ -9,6 +9,7 @@ Organization = load_model("openwisp_users", "Organization") OrganizationRadiusSettings = load_model("openwisp_radius", "OrganizationRadiusSettings") +RegisteredUser = load_model("openwisp_radius", "RegisteredUser") class ErrorDictMixin(object): @@ -33,6 +34,8 @@ def _needs_identity_verification(self, organization_filter_kwargs={}, org=None): def is_identity_verified_strong(self, user, organization=None): reg_user = None global_reg_user = None + # We use all() to utilize the prefetch cache, otherwise + # it would cause an additional query to fetch the registered user for ru in user.registered_users.all(): if organization and ru.organization_id == organization.pk: reg_user = ru diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 0ab35da7..159a91e0 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -645,7 +645,7 @@ def create(self, *args, **kwargs): try: phone_token.full_clean() if kwargs.get("enforce_unverified", True): - phone_token._validate_already_verified() + phone_token._validate_already_verified(organization=self.organization) except ValidationError as e: error_dict = self._get_error_dict(e) raise serializers.ValidationError(error_dict) @@ -754,7 +754,9 @@ def post(self, request, *args, **kwargs): _("No verification code found in the system for this user.") ) try: - is_valid = phone_token.is_valid(serializer.data["code"]) + is_valid = phone_token.is_valid( + serializer.data["code"], organization=self.organization + ) except PhoneTokenException as e: return self._error_response(str(e)) if not is_valid: @@ -763,11 +765,13 @@ def post(self, request, *args, **kwargs): reg_user, __ = RegisteredUser.get_or_create_for_user_and_org( user=user, organization=self.organization, - defaults={"is_verified": False, "method": ""}, + defaults={ + "is_verified": True, + "method": "mobile_phone", + "is_active": True, + }, ) reg_user.is_verified = True - reg_user.method = "mobile_phone" - user.is_active = True # Update username if phone_number is used as username if user.username == user.phone_number: user.username = phone_token.phone_number diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index d73a337b..b593d79c 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1058,14 +1058,20 @@ def save_user(self, user): OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") RegisteredUser = swapper.load_model("openwisp_radius", "RegisteredUser") user.save() - registered_user = RegisteredUser( + registered_user, created = RegisteredUser.get_or_create_for_user_and_org( user=user, - method="manual", organization=self.organization, + defaults={ + "method": "manual", + "is_verified": self.organization.radius_settings.needs_identity_verification, + }, ) - if self.organization.radius_settings.needs_identity_verification: + if ( + not created + and self.organization.radius_settings.needs_identity_verification + ): registered_user.is_verified = True - registered_user.save() + registered_user.save() self.users.add(user) if OrganizationUser.objects.filter( user=user, organization=self.organization @@ -1563,26 +1569,35 @@ def send_token(self): ) sms_message.send(meta_data=org_radius_settings.sms_meta_data) - def is_valid(self, token): + def is_valid(self, token, organization=None): self.attempts += 1 try: - self.verified = self.__check(token) + self.verified = self.__check(token, organization=organization) except exceptions.PhoneTokenException as phone_error: self.save() raise phone_error self.save() return self.verified - def _validate_already_verified(self): + def _validate_already_verified(self, organization=None): RegisteredUser = swapper.load_model("openwisp_radius", "RegisteredUser") - if RegisteredUser.objects.filter(user=self.user, is_verified=True).exists(): + if organization is not None: + reg_user = RegisteredUser.get_global_or_org_specific( + self.user, organization + ) + is_verified = reg_user is not None and reg_user.is_verified + else: + is_verified = RegisteredUser.objects.filter( + user=self.user, is_verified=True + ).exists() + if is_verified: logger.warning(f"User {self.user.pk} is already verified") raise exceptions.UserAlreadyVerified( _("This user has been already verified.") ) - def __check(self, token): - self._validate_already_verified() + def __check(self, token, organization=None): + self._validate_already_verified(organization=organization) if self.attempts > app_settings.SMS_TOKEN_MAX_ATTEMPTS: logger.warning( f"User {self.user} has reached the max " @@ -1613,6 +1628,7 @@ class AbstractRegisteredUser(UUIDModel): organization = models.ForeignKey( swapper.get_model_name("openwisp_users", "Organization"), on_delete=models.CASCADE, + related_name="registered_users", null=True, blank=True, verbose_name=_("organization"), @@ -1684,13 +1700,6 @@ def clean(self): _("A registration record already exists for this user/organization.") ) - @classmethod - def get_for_user_and_org(cls, user, organization): - try: - return cls.objects.get(user=user, organization=organization) - except cls.DoesNotExist: - return None - @classmethod def get_or_create_for_user_and_org(cls, user, organization, defaults=None): defaults = defaults or {} diff --git a/openwisp_radius/integrations/monitoring/tasks.py b/openwisp_radius/integrations/monitoring/tasks.py index c19a16aa..813607b1 100644 --- a/openwisp_radius/integrations/monitoring/tasks.py +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -186,6 +186,7 @@ def post_save_radiusaccounting( RegisteredUser.objects.only("method") .filter(user__username=username) .filter(Q(organization_id=organization_id) | Q(organization__isnull=True)) + .order_by("-organization_id") .first() ) if registration_method is None: diff --git a/openwisp_radius/management/commands/base/delete_unverified_users.py b/openwisp_radius/management/commands/base/delete_unverified_users.py index 8b55906a..eceb2ce7 100644 --- a/openwisp_radius/management/commands/base/delete_unverified_users.py +++ b/openwisp_radius/management/commands/base/delete_unverified_users.py @@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model from django.core.management import BaseCommand +from django.db.models import Count, Q from django.utils.timezone import now from openwisp_radius.utils import load_model @@ -33,12 +34,21 @@ def handle(self, *args, **options): if exclude_methods: exclude_methods = exclude_methods.split(",") - qs = User.objects.filter( - date_joined__lt=days, - registered_users__isnull=False, - registered_users__is_verified=False, - is_staff=False, - ).distinct() + qs = ( + User.objects.filter( + date_joined__lt=days, + registered_users__isnull=False, + is_staff=False, + ) + .annotate( + num_verified=Count( + "registered_users", + filter=Q(registered_users__is_verified=True), + ) + ) + .filter(num_verified=0) + .distinct() + ) if exclude_methods: qs = qs.exclude(registered_users__method__in=exclude_methods) diff --git a/openwisp_radius/migrations/0043_registereduser_add_uuid.py b/openwisp_radius/migrations/0043_registereduser_add_uuid.py index c85a67ac..5df26656 100644 --- a/openwisp_radius/migrations/0043_registereduser_add_uuid.py +++ b/openwisp_radius/migrations/0043_registereduser_add_uuid.py @@ -31,6 +31,7 @@ def copy_registered_users_reverse(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), + swapper.dependency("openwisp_users", "Organization"), ("openwisp_radius", "0042_set_existing_batches_completed"), ] @@ -63,6 +64,7 @@ class Migration(migrations.Migration): blank=True, help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, null=True, + related_name="registered_users", on_delete=django.db.models.deletion.CASCADE, to=swapper.get_model_name("openwisp_users", "Organization"), verbose_name="organization", diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index af874af1..c2123c9e 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.auth.management import create_permissions from django.contrib.auth.models import Permission +from django.db.models import Case, IntegerField, Value, When from ..utils import create_default_groups @@ -88,9 +89,18 @@ def copy_registered_users_ctcr_reverse( restored_objects = [] previous_user_id = None - queryset = RegisteredUserNew.objects.order_by( - "user_id", "-is_verified", "method", "pk" + # Annotate each row with an explicit verification priority so that stronger + # methods (anything that is not '' or 'email') sort before weaker ones. + # Lexical ordering of 'method' would place '' first, picking the weakest. + method_priority = Case( + When(method="", then=Value(0)), + When(method="email", then=Value(1)), + default=Value(2), + output_field=IntegerField(), ) + queryset = RegisteredUserNew.objects.annotate( + method_priority=method_priority + ).order_by("user_id", "-is_verified", "-method_priority", "pk") for registered_user in queryset.iterator(chunk_size=BATCH_SIZE): if registered_user.user_id == previous_user_id: continue @@ -187,10 +197,23 @@ def migrate_registered_users_multitenant_reverse( organization__isnull=True, ).values_list("user_id", flat=True) ) - org_records = RegisteredUser.objects.filter( - user_id__in=user_id_batch, - organization__isnull=False, - ).order_by("user_id", "-is_verified", "method", "pk") + # Annotate each row with an explicit verification priority so that stronger + # methods (anything that is not '' or 'email') sort before weaker ones. + # Lexical ordering of 'method' would place '' first, picking the weakest. + method_priority = Case( + When(method="", then=Value(0)), + When(method="email", then=Value(1)), + default=Value(2), + output_field=IntegerField(), + ) + org_records = ( + RegisteredUser.objects.filter( + user_id__in=user_id_batch, + organization__isnull=False, + ) + .annotate(method_priority=method_priority) + .order_by("user_id", "-is_verified", "-method_priority", "pk") + ) to_create = [] to_delete_pks = [] diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 1a271539..59a98fcb 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -342,6 +342,7 @@ def test_radius_user_serializer(self): data = RadiusUserSerializer(user, context={"view": view}).data with self.subTest("test full data"): + registered_user = user.registered_users.get(organization=self.default_org) self.assertEqual( data, { @@ -353,9 +354,9 @@ def test_radius_user_serializer(self): "birth_date": user.birth_date, "location": user.location, "is_active": user.is_active, - "is_verified": user.registered_users.first().is_verified, "password_expired": user.has_password_expired(), - "method": user.registered_users.first().method, + "is_verified": registered_user.is_verified, + "method": registered_user.method, "radius_user_token": user.radius_token.key, }, ) diff --git a/openwisp_radius/tests/test_api/test_rest_token.py b/openwisp_radius/tests/test_api/test_rest_token.py index e907d534..9d7d12da 100644 --- a/openwisp_radius/tests/test_api/test_rest_token.py +++ b/openwisp_radius/tests/test_api/test_rest_token.py @@ -28,7 +28,7 @@ def _get_url(self): return reverse("radius:user_auth_token", args=[self.default_org.slug]) def _post_credentials(self): - with self.assertNumQueries(22): + with self.assertNumQueries(21): return self.client.post( self._get_url(), {"username": "tester", "password": "tester"} ) diff --git a/openwisp_radius/tests/test_batch_add_users.py b/openwisp_radius/tests/test_batch_add_users.py index 2a50a006..76201888 100644 --- a/openwisp_radius/tests/test_batch_add_users.py +++ b/openwisp_radius/tests/test_batch_add_users.py @@ -143,7 +143,7 @@ def test_verified_batch_user_creation(self): "CoovaChilli-Max-Total-Octets": 3000000000, }, ) - reg_user = user.registered_users.get(organization=self.default_org) + reg_user = user.registered_users.get(organization=organization) self.assertEqual(reg_user.is_verified, True) self.assertEqual(reg_user.method, "manual") diff --git a/openwisp_radius/tests/test_commands.py b/openwisp_radius/tests/test_commands.py index 12de56e2..c6b6c205 100644 --- a/openwisp_radius/tests/test_commands.py +++ b/openwisp_radius/tests/test_commands.py @@ -276,7 +276,7 @@ def _create_old_users(): self._call_command("batch_add_users", **options) User.objects.update(date_joined=now() - timedelta(days=3)) for user in User.objects.all(): - reg_user = user.registered_users.first() + reg_user = user.registered_users.get(organization=self.default_org) reg_user.is_verified = False reg_user.method = "email" reg_user.save(update_fields=["is_verified", "method"]) @@ -374,6 +374,37 @@ def _create_old_users(): True, ) + with self.subTest( + "User verified in one org but unverified in another should not be deleted" + ): + _create_old_users() + org2 = self._create_org(name="second org", slug="second-org") + user = self._create_user( + username="multiorg_user", + email="multiorg_user@test.com", + date_joined=now() - timedelta(days=3), + ) + # Unverified registration in default org + RegisteredUser.objects.create( + user=user, + organization=self.default_org, + method="email", + is_verified=False, + ) + # Verified registration in second org + RegisteredUser.objects.create( + user=user, + organization=org2, + method="mobile_phone", + is_verified=True, + ) + self.assertEqual(User.objects.count(), 4) + call_command("delete_unverified_users", older_than_days=2) + # Users from _create_old_users (3 unverified) should be deleted, + # but the user verified in org2 must remain + self.assertEqual(User.objects.count(), 1) + self.assertEqual(User.objects.filter(pk=user.pk).exists(), True) + @capture_any_output() @patch.object( app_settings, diff --git a/openwisp_radius/tests/test_tasks.py b/openwisp_radius/tests/test_tasks.py index e99ae20f..230d2335 100644 --- a/openwisp_radius/tests/test_tasks.py +++ b/openwisp_radius/tests/test_tasks.py @@ -139,10 +139,7 @@ def test_delete_unverified_users(self): management.call_command("batch_add_users", **options) User.objects.update(date_joined=now() - timedelta(days=3)) for user in User.objects.all(): - reg_user = user.registered_users.first() - reg_user.is_verified = False - reg_user.method = "email" - reg_user.save(update_fields=["is_verified", "method"]) + user.registered_users.update(is_verified=False, method="email") self.assertEqual(User.objects.count(), 3) tasks.delete_unverified_users.delay(older_than_days=2) self.assertEqual(User.objects.count(), 0) diff --git a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py index 56fd8aac..2c7ce45c 100644 --- a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py +++ b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py @@ -61,6 +61,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), + swapper.dependency("openwisp_users", "Organization"), ("sample_radius", "0031_radiusbatch_status"), ] @@ -191,6 +192,7 @@ class Migration(migrations.Migration): blank=True, help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, null=True, + related_name="registered_users", on_delete=django.db.models.deletion.CASCADE, to=swapper.get_model_name("openwisp_users", "Organization"), verbose_name="organization", From dd7228a2c1173e95df326d5777d1809530d0c0d0 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 17 Apr 2026 14:26:16 +0530 Subject: [PATCH 08/27] [tests] Added tests --- openwisp_radius/api/views.py | 35 +++-- openwisp_radius/base/models.py | 3 +- openwisp_radius/tests/test_admin.py | 14 ++ openwisp_radius/tests/test_api/test_api.py | 45 +++++- .../tests/test_api/test_freeradius_api.py | 86 ++++++++++- .../tests/test_api/test_phone_verification.py | 69 ++++++++- .../tests/test_api/test_rest_token.py | 140 +++++++++++++++++- openwisp_radius/tests/test_models.py | 60 ++++++++ 8 files changed, 423 insertions(+), 29 deletions(-) diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 159a91e0..ff27519a 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -316,7 +316,7 @@ def post(self, request, *args, **kwargs): self.update_user_details(user) context = {"view": self, "request": request} serializer = self.serializer_class(instance=token, context=context) - response = RadiusUserSerializer(user).data + response = RadiusUserSerializer(user, context=context).data response.update(serializer.data) status_code = 200 if user.is_active else 401 # If identity verification is required, check if user is verified @@ -336,24 +336,24 @@ def validate_membership(self, user): if get_organization_radius_settings( self.organization, "registration_enabled" ): - if self._needs_identity_verification( - org=self.organization - ) and not self.is_identity_verified_strong(user, self.organization): - raise PermissionDenied try: org_user = OrganizationUser( user=user, organization=self.organization ) org_user.full_clean() org_user.save() + RegisteredUser.objects.get_or_create( + user=user, + organization=self.organization, + defaults={"method": ""}, + ) except ValidationError as error: raise serializers.ValidationError( {"non_field_errors": error.message_dict.pop("__all__")} ) else: message = _( - "{organization} does not allow self registration " - "of new accounts." + "{organization} does not allow self registration of new accounts." ).format(organization=self.organization.name) raise PermissionDenied(message) @@ -411,8 +411,8 @@ def post(self, request, *args, **kwargs): user.phone_number = ( phone_token.phone_number if phone_token else user.phone_number ) - response = RadiusUserSerializer(user).data context = {"view": self, "request": request} + response = RadiusUserSerializer(user, context=context).data token_data = rest_auth_settings.api_settings.TOKEN_SERIALIZER( token, context=context ).data @@ -621,11 +621,13 @@ class CreatePhoneTokenView( ) @swagger_auto_schema( - operation_description=(""" + operation_description=( + """ **Requires the user auth token (Bearer Token).** Used for SMS verification, sends a code via SMS to the phone number of the user. - """), + """ + ), request_body=no_body, responses={201: ""}, ) @@ -699,12 +701,14 @@ class GetPhoneTokenStatusView(DispatchOrgMixin, GenericAPIView): serializer_class = serializers.Serializer @swagger_auto_schema( - operation_description=(""" + operation_description=( + """ **Requires the user auth token (Bearer Token).** Used for SMS verification, allows checking whether an active SMS token was already requested for the mobile phone number of the logged in account. - """), + """ + ), responses={200: '`{"active":"true/false"}`'}, ) def get(self, request, *args, **kwargs): @@ -772,6 +776,7 @@ def post(self, request, *args, **kwargs): }, ) reg_user.is_verified = True + reg_user.method = "mobile_phone" # Update username if phone_number is used as username if user.username == user.phone_number: user.username = phone_token.phone_number @@ -797,11 +802,13 @@ class ChangePhoneNumberView(ThrottledAPIMixin, CreatePhoneTokenView): serializer_class = ChangePhoneNumberSerializer @swagger_auto_schema( - operation_description=(""" + operation_description=( + """ **Requires the user auth token (Bearer Token).** Allows users to change their phone number, will flag the user as inactive and send them a verification code via SMS. - """), + """ + ), responses={200: ""}, ) def post(self, request, *args, **kwargs): diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index b593d79c..ff3403c1 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1058,12 +1058,13 @@ def save_user(self, user): OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") RegisteredUser = swapper.load_model("openwisp_radius", "RegisteredUser") user.save() + radius_settings = self.organization.radius_settings registered_user, created = RegisteredUser.get_or_create_for_user_and_org( user=user, organization=self.organization, defaults={ "method": "manual", - "is_verified": self.organization.radius_settings.needs_identity_verification, + "is_verified": radius_settings.needs_identity_verification, }, ) if ( diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index b0766d5a..26468c03 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -1407,6 +1407,20 @@ def test_inline_registered_user(self): register_registration_method("github", "GitHub", strong_identity=False) self.assertIn("github", RegisteredUser._weak_verification_methods) + def test_user_admin_shows_multiple_registered_user_records(self): + user = self._create_user(username="multiuser", email="multi@test.org") + org2 = self._create_org(name="org2", slug="org2") + RegisteredUser.objects.create( + user=user, organization=self.default_org, is_verified=True + ) + RegisteredUser.objects.create(user=user, organization=org2, is_verified=False) + RegisteredUser.objects.create(user=user, organization=None, is_verified=True) + user_url = reverse(f"admin:{User._meta.app_label}_user_change", args=[user.pk]) + response = self.client.get(user_url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "id_registered_users-TOTAL_FORMS") + self.assertIn('value="3"', response.rendered_content) + def test_get_is_verified_user_admin_list(self): unknown = User.objects.first() self.assertIsNotNone(unknown) diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 59a98fcb..6dd7b40e 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -41,6 +41,7 @@ RadiusBatch = load_model("RadiusBatch") RadiusUserGroup = load_model("RadiusUserGroup") RadiusGroup = load_model("RadiusGroup") +RegisteredUser = load_model("RegisteredUser") OrganizationRadiusSettings = load_model("OrganizationRadiusSettings") Organization = swapper.load_model("openwisp_users", "Organization") OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") @@ -60,7 +61,7 @@ def _radius_batch_post_request(self, data, username="admin", password="tester"): login_payload = {"username": username, "password": password} login_url = reverse("radius:user_auth_token", args=[self.default_org.slug]) login_response = self.client.post(login_url, data=login_payload) - header = f'Bearer {login_response.json()["key"]}' + header = f"Bearer {login_response.json()['key']}" url = reverse("radius:batch") return self.client.post(url, data, HTTP_AUTHORIZATION=header) @@ -381,6 +382,48 @@ def test_radius_user_serializer(self): }, ) + with self.subTest("org-specific takes precedence over global"): + # Create user with both a global (unverified) and + # org-specific (verified) record + user2 = self._create_user(username="user2", email="user2@test.com") + self._create_org_user(user=user2, organization=self.default_org) + RegisteredUser.objects.create( + user=user2, organization=None, is_verified=False + ) + RegisteredUser.objects.create( + user=user2, + organization=self.default_org, + is_verified=True, + method="mobile_phone", + ) + url = reverse("radius:user_auth_token", args=[self.default_org.slug]) + r = self.client.post(url, {"username": "user2", "password": "tester"}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["is_verified"], True) + self.assertEqual(r.data["method"], "mobile_phone") + + with self.subTest("global record as fallback when no org-specific"): + # Create user with only a global (verified) record + user3 = self._create_user(username="user3", email="user3@test.com") + self._create_org_user(user=user3, organization=self.default_org) + RegisteredUser.objects.create( + user=user3, organization=None, is_verified=True, method="email" + ) + url = reverse("radius:user_auth_token", args=[self.default_org.slug]) + r = self.client.post(url, {"username": "user3", "password": "tester"}) + self.assertEqual(r.status_code, 200) + self.assertEqual(r.data["is_verified"], True) + self.assertEqual(r.data["method"], "email") + + with self.subTest("returns None when no RegisteredUser records exist"): + user4 = self._create_user(username="user4", email="user4@test.com") + self._create_org_user(user=user4, organization=self.default_org) + url = reverse("radius:user_auth_token", args=[self.default_org.slug]) + r = self.client.post(url, {"username": "user4", "password": "tester"}) + self.assertEqual(r.status_code, 200) + self.assertIsNone(r.data["is_verified"]) + self.assertIsNone(r.data["method"]) + # The fallback value is set on project startup, hence it also requires mocking. @mock.patch.object( OrganizationRadiusSettings._meta.get_field("first_name"), diff --git a/openwisp_radius/tests/test_api/test_freeradius_api.py b/openwisp_radius/tests/test_api/test_freeradius_api.py index 64968edd..3a243268 100644 --- a/openwisp_radius/tests/test_api/test_freeradius_api.py +++ b/openwisp_radius/tests/test_api/test_freeradius_api.py @@ -206,6 +206,88 @@ def test_authorize_unverified_user(self): self.assertEqual(response.status_code, 200) self.assertIsNone(response.data) + def test_authorize_verified_user(self): + org_user = self._get_org_user() + user = org_user.user + org_settings = OrganizationRadiusSettings.objects.get( + organization=self._get_org() + ) + org_settings.needs_identity_verification = True + org_settings.save() + + with self.subTest("org-specific verified record passes authorization"): + RegisteredUser.objects.create( + user=user, organization=self._get_org(), is_verified=True + ) + response = self._authorize_user(auth_header=self.auth_header) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {"control:Auth-Type": "Accept"}) + + with self.subTest("global verified record passes authorization (fallback)"): + RegisteredUser.objects.filter(user=user).delete() + RegisteredUser.objects.create( + user=user, organization=None, is_verified=True + ) + response = self._authorize_user(auth_header=self.auth_header) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, {"control:Auth-Type": "Accept"}) + + def test_multi_org_user_different_verification_states(self): + org1 = self._get_org() + org_settings = OrganizationRadiusSettings.objects.get(organization=org1) + org_settings.needs_identity_verification = True + org_settings.save() + org2 = self._create_org(name="org2", slug="org2") + org2_settings = OrganizationRadiusSettings.objects.get_or_create( + organization=org2 + )[0] + org2_settings.needs_identity_verification = True + org2_settings.full_clean() + org2_settings.save() + user = self._get_user_with_org() + self._create_org_user(organization=org2, user=user) + RegisteredUser.objects.create(user=user, organization=org1, is_verified=True) + auth_header_org1 = f"Bearer {org1.pk} {org1.radius_settings.token}" + response = self._authorize_user( + username=user.username, auth_header=auth_header_org1 + ) + self.assertEqual(response.data["control:Auth-Type"], "Accept") + + auth_header_org2 = f"Bearer {org2.pk} {org2.radius_settings.token}" + response = self._authorize_user( + username=user.username, auth_header=auth_header_org2 + ) + self.assertIsNone(response.data) + + def test_global_fallback_for_orgs_without_specific_records(self): + org1 = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + org2_settings = OrganizationRadiusSettings.objects.get_or_create( + organization=org2 + )[0] + org2_settings.needs_identity_verification = True + org2_settings.full_clean() + org2_settings.save() + user = self._get_user_with_org() + self._create_org_user(organization=org2, user=user) + RegisteredUser.objects.create(user=user, organization=None, is_verified=True) + org_settings = OrganizationRadiusSettings.objects.get(organization=org1) + org_settings.needs_identity_verification = True + org_settings.save() + user.registered_users.exclude(organization=None).delete() + + auth_header_org1 = f"Bearer {org1.pk} {org1.radius_settings.token}" + response = self._authorize_user( + username=user.username, auth_header=auth_header_org1 + ) + self.assertEqual(response.data["control:Auth-Type"], "Accept") + + auth_header_org2 = f"Bearer {org2.pk} {org2.radius_settings.token}" + response = self._authorize_user( + username=user.username, auth_header=auth_header_org2 + ) + self.assertEqual(response.data["control:Auth-Type"], "Accept") + def test_authorize_radius_token_unverified_user(self): user = self._get_org_user() org_settings = OrganizationRadiusSettings.objects.get( @@ -258,7 +340,7 @@ def test_postauth_radius_token_accept_201(self): def test_postauth_accept_201_querystring(self): self.assertEqual(RadiusPostAuth.objects.all().count(), 0) params = self._get_postauth_params() - post_url = f'{reverse("radius:postauth")}{self.token_querystring}' + post_url = f"{reverse('radius:postauth')}{self.token_querystring}" response = self.client.post(post_url, params) params["password"] = "" self.assertEqual(RadiusPostAuth.objects.filter(**params).count(), 1) @@ -2442,7 +2524,7 @@ def test_cache(self): ) self._get_org_user() token_querystring = f"?token={rad.token}&uuid={str(self.org.pk)}" - post_url = f'{reverse("radius:authorize")}{token_querystring}' + post_url = f"{reverse('radius:authorize')}{token_querystring}" # Clear cache before sending request cache.clear() self.client.post(post_url, {"username": "tester", "password": "tester"}) diff --git a/openwisp_radius/tests/test_api/test_phone_verification.py b/openwisp_radius/tests/test_api/test_phone_verification.py index 3812dcdb..8e614f5c 100644 --- a/openwisp_radius/tests/test_api/test_phone_verification.py +++ b/openwisp_radius/tests/test_api/test_phone_verification.py @@ -23,6 +23,7 @@ User = get_user_model() PhoneToken = load_model("PhoneToken") RadiusToken = load_model("RadiusToken") +RegisteredUser = load_model("RegisteredUser") OrganizationRadiusSettings = load_model("OrganizationRadiusSettings") OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") @@ -223,9 +224,23 @@ def test_create_phone_token_400_user_already_verified(self): reg_user.save() token.user.save() url = reverse("radius:phone_token_create", args=[self.default_org.slug]) - r = self.client.post(url, HTTP_AUTHORIZATION=f"Bearer {token.key}") - self.assertEqual(r.status_code, 400) - self.assertEqual(r.json(), {"user": "This user has been already verified."}) + + with self.subTest("org-specific verified record blocks phone token creation"): + response = self.client.post(url, HTTP_AUTHORIZATION=f"Bearer {token.key}") + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json(), {"user": "This user has been already verified."} + ) + + with self.subTest("global verified record also blocks phone token creation"): + # Replace org-specific record with a global verified record + reg_user.delete() + RegisteredUser.objects.create( + user=token.user, organization=None, is_verified=True + ) + r = self.client.post(url, HTTP_AUTHORIZATION=f"Bearer {token.key}") + self.assertEqual(r.status_code, 400) + self.assertEqual(r.json(), {"user": "This user has been already verified."}) @freeze_time(_TEST_DATE) @capture_any_output() @@ -942,6 +957,54 @@ def test_phone_number_change_update_username(self): self.assertEqual(user.phone_number, new_phone_number) self.assertEqual(user.username, new_phone_number) + @capture_any_output() + @mock.patch("openwisp_radius.utils.SmsMessage.send") + def test_phone_change_unverifies_only_specific_org(self, *args): + org2 = self._create_org(name="org2", slug="org2") + org2_settings = OrganizationRadiusSettings.objects.get_or_create( + organization=org2 + )[0] + org2_settings.sms_verification = True + org2_settings.needs_method = True + org2_settings.sms_sender = "+595972157632" + org2_settings.full_clean() + org2_settings.save() + self._create_org_user(organization=org2) + + self._register_user(expect_users=None) + user = User.objects.get(email=self._test_email) + user_token = Token.objects.get(user=user) + + phone_token = PhoneToken.objects.create( + user=user, + ip="127.0.0.1", + phone_number="+393664255801", + ) + url = reverse("radius:phone_token_validate", args=[self.default_org.slug]) + response = self.client.post( + url, + json.dumps({"code": phone_token.token}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + + reg_org1 = RegisteredUser.objects.get(user=user, organization=self.default_org) + self.assertEqual(reg_org1.is_verified, True) + + url = reverse("radius:phone_number_change", args=[self.default_org.slug]) + with mock.patch("openwisp_radius.utils.SmsMessage.send"): + response = self.client.post( + url, + json.dumps({"phone_number": "+595972157444"}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + + reg_org1.refresh_from_db() + self.assertEqual(reg_org1.is_verified, False) + class TestIsSmsVerificationEnabled(ApiTokenMixin, BaseTestCase): def setUp(self): diff --git a/openwisp_radius/tests/test_api/test_rest_token.py b/openwisp_radius/tests/test_api/test_rest_token.py index 9d7d12da..be26a7fb 100644 --- a/openwisp_radius/tests/test_api/test_rest_token.py +++ b/openwisp_radius/tests/test_api/test_rest_token.py @@ -218,20 +218,26 @@ def test_unverified_registered_user_different_organization(self): with self.subTest("Test RegisteredUser object does not exist"): response = self.client.post(url, user_cred) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 401) + self.assertEqual( + OrganizationUser.objects.filter(user=user, organization=org2).count(), + 1, + ) + self.assertEqual( + RegisteredUser.objects.filter(user=user, organization=org2).count(), + 1, + ) - registered_user = RegisteredUser.objects.create( - user=user, organization=org2, method="" - ) + registered_user = RegisteredUser.objects.get(user=user, organization=org2) with self.subTest("Test unverified user without registration method"): response = self.client.post(url, user_cred) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 401) with self.subTest("Test verified user without registration method"): registered_user.is_verified = True registered_user.save() response = self.client.post(url, user_cred) - self.assertEqual(response.status_code, 403) + self.assertEqual(response.status_code, 401) with self.subTest("Test verified user with mobile registration method"): registered_user.method = "mobile_phone" @@ -241,11 +247,10 @@ def test_unverified_registered_user_different_organization(self): self.assertIn("key", response.data) OrganizationUser.objects.filter(organization=org2, user=user).delete() + RegisteredUser.objects.filter(user=user, organization=org2).delete() with self.subTest( "Test unverified user organization does not need identity verification" ): - registered_user.is_verified = False - registered_user.save() rad_settings.needs_identity_verification = False rad_settings.save() @@ -402,3 +407,122 @@ def test_validate_auth_token_password_expired(self): User.objects.update(password_updated=now() - timedelta(days=60)) response = self._test_validate_auth_token_helper(user) self.assertEqual(response.data["password_expired"], True) + + @capture_any_output() + @mock.patch("openwisp_radius.utils.SmsMessage.send") + def test_multi_org_phone_verification_flow(self, *args): + org_a = self.default_org + org_a.radius_settings.sms_verification = True + org_a.radius_settings.sms_sender = "+595972157632" + org_a.radius_settings.full_clean() + org_a.radius_settings.save() + + org_b = self._create_org(name="OrgB", slug="orgb") + OrganizationRadiusSettings.objects.create( + organization=org_b, + sms_verification=True, + needs_identity_verification=True, + sms_sender="+595972157633", + ) + + with self.subTest("Register with OrgA"): + url = reverse("radius:rest_register", args=[org_a.slug]) + response = self.client.post( + url, + { + "username": "multiorguser", + "email": "multiorg@test.org", + "password1": "tester", + "password2": "tester", + "phone_number": "+393664255801", + "method": "mobile_phone", + }, + ) + self.assertEqual(response.status_code, 201) + user = User.objects.get(email="multiorg@test.org") + self.assertEqual( + RegisteredUser.objects.filter(user=user, organization=org_a).count(), + 1, + ) + self.assertEqual( + RegisteredUser.objects.get(user=user, organization=org_a).is_verified, + False, + ) + + with self.subTest("Complete phone verification for OrgA"): + user_token = Token.objects.get(user=user) + phone_token = PhoneToken.objects.create( + user=user, ip="127.0.0.1", phone_number="+393664255801" + ) + url = reverse("radius:phone_token_validate", args=[org_a.slug]) + response = self.client.post( + url, + {"code": phone_token.token}, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + RegisteredUser.objects.get(user=user, organization=org_a).is_verified, + True, + ) + + with self.subTest("User is verified in OrgA but not in OrgB"): + self.assertTrue( + RegisteredUser.objects.get(user=user, organization=org_a).is_verified + ) + self.assertEqual( + RegisteredUser.objects.filter(user=user, organization=org_b).count(), + 0, + ) + + with self.subTest("Login to OrgB creates OrganizationUser and RegisteredUser"): + url = reverse("radius:user_auth_token", args=[org_b.slug]) + response = self.client.post( + url, {"username": "multiorguser", "password": "tester"} + ) + self.assertEqual(response.status_code, 401) + self.assertEqual(response.data.get("is_verified"), False) + self.assertEqual( + OrganizationUser.objects.filter(user=user, organization=org_b).count(), + 1, + ) + self.assertEqual( + RegisteredUser.objects.filter(user=user, organization=org_b).count(), + 1, + ) + self.assertEqual( + RegisteredUser.objects.get(user=user, organization=org_b).is_verified, + False, + ) + + with self.subTest("Complete phone verification for OrgB"): + user_token = Token.objects.get(user=user) + phone_token = PhoneToken.objects.create( + user=user, ip="127.0.0.1", phone_number="+393664255802" + ) + url = reverse("radius:phone_token_validate", args=[org_b.slug]) + response = self.client.post( + url, + {"code": phone_token.token}, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + RegisteredUser.objects.get(user=user, organization=org_b).is_verified, + True, + ) + + with self.subTest("User can now login to OrgB"): + url = reverse("radius:user_auth_token", args=[org_b.slug]) + response = self.client.post( + url, {"username": "multiorguser", "password": "tester"} + ) + self.assertEqual( + response.status_code, + 200, + f"Login failed: {response.status_code} - {response.data}", + ) + self.assertEqual(response.data["is_verified"], True) + self.assertEqual(response.data["method"], "mobile_phone") diff --git a/openwisp_radius/tests/test_models.py b/openwisp_radius/tests/test_models.py index c618a29e..b23a8478 100644 --- a/openwisp_radius/tests/test_models.py +++ b/openwisp_radius/tests/test_models.py @@ -42,6 +42,7 @@ RadiusBatch = load_model("RadiusBatch") OrganizationRadiusSettings = load_model("OrganizationRadiusSettings") Organization = swapper.load_model("openwisp_users", "Organization") +RegisteredUser = load_model("RegisteredUser") class TestNas(BaseTestCase): @@ -1218,5 +1219,64 @@ def test_sessions_with_multiple_orgs(self, mocked_radclient): self.assertEqual(org2_session.groupname, f"{org2.slug}-users") +class TestRegisteredUser(BaseTestCase): + def test_get_global_or_org_specific(self): + user = self._create_user() + org = self._create_org(name="ru-test-org", slug="ru-test-org") + + with self.subTest("returns None when no records exist"): + result = RegisteredUser.get_global_or_org_specific(user, org) + self.assertIsNone(result) + + with self.subTest("returns global record as fallback"): + global_ru = RegisteredUser.objects.create( + user=user, organization=None, is_verified=True + ) + result = RegisteredUser.get_global_or_org_specific(user, org) + self.assertIsNone(result.organization) + self.assertEqual(result.is_verified, True) + + with self.subTest("org-specific preferred over global"): + global_ru.is_verified = False + global_ru.save() + org_ru = RegisteredUser.objects.create( + user=user, organization=org, is_verified=True + ) + result = RegisteredUser.get_global_or_org_specific(user, org) + self.assertEqual(result.organization, org) + self.assertEqual(result.is_verified, True) + + with self.subTest( + "org-specific returned even when global is verified and org-specific is not" + ): + org_ru.is_verified = False + org_ru.save() + global_ru.is_verified = True + global_ru.save() + result = RegisteredUser.get_global_or_org_specific(user, org) + self.assertEqual(result.organization, org) + self.assertEqual(result.is_verified, False) + + with self.subTest("returns global record when organization=None passed"): + result = RegisteredUser.get_global_or_org_specific(user, organization=None) + self.assertIsNone(result.organization) + + def test_clean_prevents_duplicate_registered_user(self): + user = self._create_user() + org = self._create_org(name="dup-test-org", slug="dup-test-org") + + with self.subTest("duplicate org-specific raises ValidationError"): + RegisteredUser.objects.create(user=user, organization=org) + duplicate = RegisteredUser(user=user, organization=org) + with self.assertRaises(ValidationError): + duplicate.full_clean() + + with self.subTest("duplicate global raises ValidationError"): + RegisteredUser.objects.create(user=user, organization=None) + duplicate = RegisteredUser(user=user, organization=None) + with self.assertRaises(ValidationError): + duplicate.full_clean() + + del BaseTestCase del BaseTransactionTestCase From 13295f08f697b8c8f8d8b4fcc919cb93fdb716cb Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 17 Apr 2026 18:17:35 +0530 Subject: [PATCH 09/27] [feature] Added REST API endpoint for the user to update registration method in cross-org login --- docs/user/rest-api.rst | 42 ++++++ docs/user/settings.rst | 2 + openwisp_radius/api/serializers.py | 41 +++++ openwisp_radius/api/urls.py | 5 + openwisp_radius/api/views.py | 70 +++++++-- openwisp_radius/base/models.py | 12 +- .../integrations/monitoring/tasks.py | 4 + .../monitoring/tests/test_metrics.py | 70 ++++++--- .../integrations/monitoring/utils.py | 2 + openwisp_radius/registration.py | 1 + openwisp_radius/tests/test_api/test_api.py | 141 +++++++++++++++++- .../tests/test_api/test_rest_token.py | 7 +- 12 files changed, 359 insertions(+), 38 deletions(-) diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index 0efc1882..68ed71a3 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -803,6 +803,48 @@ Param Description phone_number string ============ =========== +Update Registered User Method ++++++++++++++++++++++++++++++ + +**Requires the user auth token (Bearer Token)**. + +Allows users to update their registered user method for an organization. +The method can only be updated when it is currently set to +``pending_verification``. Once updated, it cannot be changed again via +this endpoint. + +This endpoint is used during cross-organization login when a user +authenticates to a new organization. The user must complete verification +for that organization before they can create account with the new +organization. + +.. code-block:: text + + /api/v1/radius/organization//account/registration-method/ + +Responds only to **POST**. + +Parameters: + +====== =========== +Param Description +====== =========== +method string (\*) +====== =========== + +(\*) ``method`` must be one of the available +:ref:`registration/verification methods +`, excluding +``pending_verification``. + +**Success Response (200 OK)**: + +.. code-block:: json + + { + "method": "mobile_phone" + } + .. _radius_batch_user_creation: Batch user creation diff --git a/docs/user/settings.rst b/docs/user/settings.rst index 4df3d1c2..597a933c 100644 --- a/docs/user/settings.rst +++ b/docs/user/settings.rst @@ -696,6 +696,8 @@ verification method. The following choices are available by default: - ``mobile_phone``: Mobile phone number :ref:`verification via SMS ` - ``social_login``: :doc:`social login feature ` +- ``pending_verification``: Transitional state used when a user authenticates + to a new organization but has not yet completed verification for that organization. .. note:: diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index b099ecc7..9c98cb1f 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -765,6 +765,47 @@ def save(self): reg_user.save() +class UpdateRegisteredUserMethodSerializer(ValidatedModelSerializer): + method = serializers.ChoiceField( + choices=[ + choice + for choice in REGISTRATION_METHOD_CHOICES + if choice[0] != "pending_verification" + ], + help_text=_( + "The registration method to set for the user. " + "Cannot be 'pending_verification'." + ), + ) + + class Meta: + model = RegisteredUser + fields = ["method"] + + def validate_method(self, value): + if value == "pending_verification": + raise serializers.ValidationError( + _("'pending_verification' cannot be set as a registration method.") + ) + return value + + def validate(self, attrs): + if self.instance.method != "pending_verification": + raise serializers.ValidationError( + { + "method": _( + "Method can only be updated from pending verification state." + ) + } + ) + return attrs + + def update(self, instance, validated_data): + instance.method = validated_data["method"] + instance.save() + return instance + + class RadiusUserSerializer(serializers.ModelSerializer): """ Used to return information about the logged in user diff --git a/openwisp_radius/api/urls.py b/openwisp_radius/api/urls.py index 88d02572..3ca6407d 100644 --- a/openwisp_radius/api/urls.py +++ b/openwisp_radius/api/urls.py @@ -77,6 +77,11 @@ def get_api_urls(api_views=None): api_views.change_phone_number, name="phone_number_change", ), + path( + "radius/organization//account/registration-method/", + api_views.update_registered_user_registration_method, + name="update_registered_user_registration_method", + ), path("radius/batch/", api_views.batch, name="batch"), path( "radius/organization//batch//pdf/", diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index ff27519a..bb647df8 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -35,6 +35,7 @@ ListCreateAPIView, RetrieveAPIView, RetrieveUpdateDestroyAPIView, + get_object_or_404, ) from rest_framework.pagination import PageNumberPagination from rest_framework.permissions import ( @@ -74,6 +75,7 @@ RadiusBatchSerializer, RadiusGroupSerializer, RadiusUserGroupSerializer, + UpdateRegisteredUserMethodSerializer, UserRadiusUsageSerializer, ValidatePhoneTokenSerializer, ) @@ -345,7 +347,7 @@ def validate_membership(self, user): RegisteredUser.objects.get_or_create( user=user, organization=self.organization, - defaults={"method": ""}, + defaults={"method": "pending_verification"}, ) except ValidationError as error: raise serializers.ValidationError( @@ -621,13 +623,11 @@ class CreatePhoneTokenView( ) @swagger_auto_schema( - operation_description=( - """ + operation_description=(""" **Requires the user auth token (Bearer Token).** Used for SMS verification, sends a code via SMS to the phone number of the user. - """ - ), + """), request_body=no_body, responses={201: ""}, ) @@ -701,14 +701,12 @@ class GetPhoneTokenStatusView(DispatchOrgMixin, GenericAPIView): serializer_class = serializers.Serializer @swagger_auto_schema( - operation_description=( - """ + operation_description=(""" **Requires the user auth token (Bearer Token).** Used for SMS verification, allows checking whether an active SMS token was already requested for the mobile phone number of the logged in account. - """ - ), + """), responses={200: '`{"active":"true/false"}`'}, ) def get(self, request, *args, **kwargs): @@ -802,13 +800,11 @@ class ChangePhoneNumberView(ThrottledAPIMixin, CreatePhoneTokenView): serializer_class = ChangePhoneNumberSerializer @swagger_auto_schema( - operation_description=( - """ + operation_description=(""" **Requires the user auth token (Bearer Token).** Allows users to change their phone number, will flag the user as inactive and send them a verification code via SMS. - """ - ), + """), responses={200: ""}, ) def post(self, request, *args, **kwargs): @@ -836,6 +832,54 @@ def create_phone_token(self, *args, **kwargs): change_phone_number = ChangePhoneNumberView.as_view() +class UpdateRegisteredUserMethodView(DispatchOrgMixin, GenericAPIView): + authentication_classes = (BearerAuthentication, SessionAuthentication) + permission_classes = (IsAuthenticated,) + serializer_class = UpdateRegisteredUserMethodSerializer + + @swagger_auto_schema( + operation_description=(""" + **Requires the user auth token (Bearer Token).** + Allows users to update their registered user method for an organization. + The method can only be updated when it is currently + set to 'pending_verification'. + Once updated, it cannot be changed again via this endpoint. + """), + responses={ + 200: "Method updated successfully", + 400: ( + "Invalid request (method is not 'pending_verification' " + "or invalid method value)" + ), + 401: "Authentication required", + 404: "RegisteredUser not found for this user and organization", + }, + ) + def post(self, request, slug): + user = request.user + try: + reg_user = get_object_or_404( + RegisteredUser, + user_id=user.pk, + organization=self.organization, + ) + except RegisteredUser.DoesNotExist: + raise NotFound( + _("RegisteredUser not found for this user and organization.") + ) + serializer = self.get_serializer( + instance=reg_user, data=request.data, partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response( + {"method": serializer.instance.method}, status=status.HTTP_200_OK + ) + + +update_registered_user_registration_method = UpdateRegisteredUserMethodView.as_view() + + class RadiusAccountingFilter(AccountingFilter): called_station_id = CharFilter( field_name="called_station_id", method="filter_mac_address" diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index ff3403c1..41d428c7 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1668,7 +1668,7 @@ class AbstractRegisteredUser(UUIDModel): default=False, ) modified = AutoLastModifiedField(_("Last verification change"), editable=True) - _weak_verification_methods = {"", "email"} + _weak_verification_methods = {"", "email", "pending_verification"} @property def is_identity_verified_strong(self): @@ -1724,14 +1724,18 @@ def get_global_or_org_specific(cls, user, organization=None): def unverify_inactive_users(cls): if not app_settings.UNVERIFY_INACTIVE_USERS: return - # Exclude users who have unspecified, manual, or email + # Exclude users who have unspecified, manual, email, or pending_verification # registration method because such users don't have an option # to re-verify. See https://github.com/openwisp/openwisp-radius/issues/517 - cls.objects.exclude(method__in=["", "manual", "email"]).filter( + cls.objects.exclude( + method__in=["", "manual", "email", "pending_verification"] + ).filter( user__is_staff=False, user__last_login__lt=timezone.now() - timedelta(days=app_settings.UNVERIFY_INACTIVE_USERS), - ).update(is_verified=False) + ).update( + is_verified=False + ) @classmethod def delete_inactive_users(cls): diff --git a/openwisp_radius/integrations/monitoring/tasks.py b/openwisp_radius/integrations/monitoring/tasks.py index 813607b1..e4e4f8a0 100644 --- a/openwisp_radius/integrations/monitoring/tasks.py +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -97,6 +97,8 @@ def _write_user_signup_metric_for_all(metric_key): for method, count in total_registered_users.items(): method = clean_registration_method(method) + if method is None: + continue metric = get_metric_func(organization_id="__all__", registration_method=method) metric_data.append((metric, {"value": count})) Metric.batch_write(metric_data) @@ -145,6 +147,8 @@ def _write_user_signup_metrics_for_orgs(metric_key): for org_id, registration_method, count in registered_users: registration_method = clean_registration_method(registration_method) + if registration_method is None: + continue if registration_method == "unspecified": count += users_without_registereduser.get(org_id, 0) metric = get_metric_func( diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index d1a754e2..3e2596f4 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -21,6 +21,11 @@ @tag("radius_monitoring") class TestMetrics(CreateDeviceMonitoringMixin, BaseTransactionTestCase): + def _read_chart(chart, **kwargs): + return chart.read( + additional_query_kwargs={"additional_params": kwargs}, + ) + def _create_registered_user(self, **kwargs): options = { "is_verified": False, @@ -375,11 +380,6 @@ def test_post_save_radius_accounting_registereduser_not_found(self, mocked_logge def test_write_user_registration_metrics(self): from ..tasks import write_user_registration_metrics - def _read_chart(chart, **kwargs): - return chart.read( - additional_query_kwargs={"additional_params": kwargs}, - ) - # The TransactionTestCase truncates all the data after each test. # The general metrics and charts which are created by migrations # get deleted after each test. Therefore, we create them again here. @@ -397,21 +397,25 @@ def _read_chart(chart, **kwargs): write_user_registration_metrics.delay() user_signup_chart = user_signup_metric.chart_set.first() - all_points = _read_chart(user_signup_chart, organization_id=["__all__"]) + all_points = self._read_chart( + user_signup_chart, organization_id=["__all__"] + ) self.assertEqual(all_points["traces"][0][0], "unspecified") self.assertEqual(all_points["traces"][0][1][-1], 1) self.assertEqual(all_points["summary"], {"unspecified": 1}) - org_points = _read_chart(user_signup_chart, organization_id=[str(org.id)]) + org_points = self._read_chart( + user_signup_chart, organization_id=[str(org.id)] + ) self.assertEqual(len(org_points["traces"]), 0) total_user_signup_chart = total_user_signup_metric.chart_set.first() - all_points = _read_chart( + all_points = self._read_chart( total_user_signup_chart, organization_id=["__all__"] ) self.assertEqual(all_points["traces"][0][0], "unspecified") self.assertEqual(all_points["traces"][0][1][-1], 1) self.assertEqual(all_points["summary"], {"unspecified": 1}) - org_points = _read_chart( + org_points = self._read_chart( total_user_signup_chart, organization_id=[str(org.id)] ) self.assertEqual(len(org_points["traces"]), 0) @@ -425,23 +429,27 @@ def _read_chart(chart, **kwargs): write_user_registration_metrics.delay() user_signup_chart = user_signup_metric.chart_set.first() - all_points = _read_chart(user_signup_chart, organization_id=["__all__"]) + all_points = self._read_chart( + user_signup_chart, organization_id=["__all__"] + ) self.assertEqual(all_points["traces"][0][0], "unspecified") self.assertEqual(all_points["traces"][0][1][-1], 1) self.assertEqual(all_points["summary"], {"unspecified": 1}) - org_points = _read_chart(user_signup_chart, organization_id=[str(org.id)]) + org_points = self._read_chart( + user_signup_chart, organization_id=[str(org.id)] + ) self.assertEqual(all_points["traces"][0][0], "unspecified") self.assertEqual(all_points["traces"][0][1][-1], 1) self.assertEqual(all_points["summary"], {"unspecified": 1}) total_user_signup_chart = total_user_signup_metric.chart_set.first() - all_points = _read_chart( + all_points = self._read_chart( total_user_signup_chart, organization_id=["__all__"] ) self.assertEqual(all_points["traces"][0][0], "unspecified") self.assertEqual(all_points["traces"][0][1][-1], 1) self.assertEqual(all_points["summary"], {"unspecified": 1}) - org_points = _read_chart( + org_points = self._read_chart( total_user_signup_chart, organization_id=[str(org.id)] ) self.assertEqual(all_points["traces"][0][0], "unspecified") @@ -458,13 +466,17 @@ def _read_chart(chart, **kwargs): write_user_registration_metrics.delay() user_signup_chart = user_signup_metric.chart_set.first() - all_points = _read_chart(user_signup_chart, organization_id=["__all__"]) + all_points = self._read_chart( + user_signup_chart, organization_id=["__all__"] + ) self.assertEqual(all_points["traces"][0][0], "mobile_phone") self.assertEqual(all_points["traces"][0][1][-1], 1) self.assertEqual( all_points["summary"], {"mobile_phone": 1, "unspecified": 0} ) - org_points = _read_chart(user_signup_chart, organization_id=[str(org.id)]) + org_points = self._read_chart( + user_signup_chart, organization_id=[str(org.id)] + ) self.assertEqual(all_points["traces"][0][0], "mobile_phone") self.assertEqual(all_points["traces"][0][1][-1], 1) self.assertEqual( @@ -472,7 +484,7 @@ def _read_chart(chart, **kwargs): ) total_user_signup_chart = total_user_signup_metric.chart_set.first() - org_points = _read_chart( + org_points = self._read_chart( total_user_signup_chart, organization_id=["__all__"] ) self.assertEqual(org_points["traces"][0][0], "mobile_phone") @@ -480,7 +492,7 @@ def _read_chart(chart, **kwargs): self.assertEqual( org_points["summary"], {"mobile_phone": 1, "unspecified": 0} ) - org_points = _read_chart( + org_points = self._read_chart( total_user_signup_chart, organization_id=[str(org.id)] ) self.assertEqual(all_points["traces"][0][0], "mobile_phone") @@ -488,3 +500,27 @@ def _read_chart(chart, **kwargs): self.assertEqual( all_points["summary"], {"mobile_phone": 1, "unspecified": 0} ) + + def test_pending_verification_excluded_from_metrics(self): + from ..tasks import write_user_registration_metrics + + cache.clear() + create_general_metrics(None, None) + org = self._create_org(name="pending_verification_test_org") + user_signup_metric = self.metric_model.objects.get(key="user_signups") + total_user_signup_metric = self.metric_model.objects.get(key="tot_user_signups") + user = self._create_org_user(organization=org).user + self._create_registered_user( + user=user, organization=org, method="pending_verification" + ) + write_user_registration_metrics.delay() + + user_signup_chart = user_signup_metric.chart_set.first() + all_points = self._read_chart(user_signup_chart, organization_id=[str(org.pk)]) + self.assertEqual(len(all_points["traces"]), 0) + + total_user_signup_chart = total_user_signup_metric.chart_set.first() + all_points = self._read_chart( + total_user_signup_chart, organization_id=[str(org.pk)] + ) + self.assertEqual(len(all_points["traces"]), 0) diff --git a/openwisp_radius/integrations/monitoring/utils.py b/openwisp_radius/integrations/monitoring/utils.py index 6fdb8eee..2528f479 100644 --- a/openwisp_radius/integrations/monitoring/utils.py +++ b/openwisp_radius/integrations/monitoring/utils.py @@ -51,4 +51,6 @@ def sha1_hash(input_string): def clean_registration_method(method): if method == "": method = "unspecified" + elif method == "pending_verification": + return None return method diff --git a/openwisp_radius/registration.py b/openwisp_radius/registration.py index e376232d..178120ff 100644 --- a/openwisp_radius/registration.py +++ b/openwisp_radius/registration.py @@ -10,6 +10,7 @@ ("manual", _("Manually created")), ("email", _("Email")), ("mobile_phone", _("Mobile phone")), + ("pending_verification", _("Pending Verification")), ] AUTHORIZE_UNVERIFIED = [] diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 6dd7b40e..3acea4aa 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -65,6 +65,30 @@ def _radius_batch_post_request(self, data, username="admin", password="tester"): url = reverse("radius:batch") return self.client.post(url, data, HTTP_AUTHORIZATION=header) + def _get_update_method_url(self, org=None): + if org is None: + org = self.default_org + return reverse( + "radius:update_registered_user_registration_method", args=[org.slug] + ) + + def _create_pending_verification_user(self): + user = self._create_user( + username="pendinguser", + password="tester", + email="pendinguser@test.com", + ) + org2 = self._create_org(name="org2") + OrganizationUser.objects.create(user=user, organization=org2) + RegisteredUser.objects.create( + user=user, + organization=org2, + method="pending_verification", + is_verified=False, + ) + user_token = Token.objects.create(user=user) + return user, org2, user_token + def test_batch_bad_request_400(self): self.assertEqual(RadiusBatch.objects.count(), 0) data = self._radius_batch_prefix_data(number_of_users=-1) @@ -970,7 +994,7 @@ def test_user_accounting_list_200(self): response = self.client.post( auth_url, {"username": "tester", "password": "tester"} ) - authorization = f'Bearer {response.data["key"]}' + authorization = f"Bearer {response.data['key']}" stop_time = "2018-03-02T11:43:24.020460+01:00" data1 = self.acct_post_data data1.update( @@ -1610,6 +1634,119 @@ def test_radius_user_group_serializer_without_view_context(self): self.assertEqual(serializer._user, None) self.assertEqual(serializer.fields["group"].queryset.count(), 0) + def test_update_registered_user_method_success(self): + user, org2, user_token = self._create_pending_verification_user( + suffix="_success" + ) + url = self._get_update_method_url(org2) + response = self.client.post( + url, + {"method": "mobile_phone"}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["method"], "mobile_phone") + registered_user = RegisteredUser.objects.get(user=user, organization=org2) + self.assertEqual(registered_user.method, "mobile_phone") + self.assertEqual(registered_user.is_verified, False) + + def test_update_registered_user_method_with_valid_methods(self): + user, org2, user_token = self._create_pending_verification_user(suffix="_valid") + url = self._get_update_method_url(org2) + for method in ["", "manual", "email", "mobile_phone"]: + with self.subTest(method=method): + registered_user = RegisteredUser.objects.get( + user=user, organization=org2 + ) + registered_user.method = "pending_verification" + registered_user.save() + response = self.client.post( + url, + {"method": method}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["method"], method) + + def test_update_registered_user_method_validation_errors(self): + user, org2, user_token = self._create_pending_verification_user() + url = self._get_update_method_url(org2) + with self.subTest("reject_pending_verification_as_input"): + response = self.client.post( + url, + {"method": "pending_verification"}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("reject_invalid_method"): + response = self.client.post( + url, + {"method": "invalid_method"}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 400) + + with self.subTest("reject_non_pending_state"): + registered_user = RegisteredUser.objects.get(user=user, organization=org2) + registered_user.method = "mobile_phone" + registered_user.save() + response = self.client.post( + url, + {"method": "email"}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("pending verification", response.data["method"][0]) + + def test_update_registered_user_method_404_cases(self): + with self.subTest("not_found_without_registered_user"): + user = self._create_user(username="noreguser", password="tester") + user_token = Token.objects.create(user=user) + url = self._get_update_method_url() + response = self.client.post( + url, + {"method": "mobile_phone"}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 404) + + with self.subTest("only_owner_can_update"): + user, org2, user_token = self._create_pending_verification_user( + suffix="_owner" + ) + other_user = self._create_user( + username="otheruser", password="tester", email="otheruser@test.com" + ) + other_user_token = Token.objects.create(user=other_user) + url = self._get_update_method_url(org2) + response = self.client.post( + url, + {"method": "mobile_phone"}, + HTTP_AUTHORIZATION=f"Bearer {other_user_token.key}", + ) + self.assertEqual(response.status_code, 404) + + with self.subTest("invalid_org"): + user, _, user_token = self._create_pending_verification_user( + suffix="_invalid_org" + ) + url = reverse( + "radius:update_registered_user_registration_method", + args=["nonexistent-org-slug"], + ) + response = self.client.post( + url, + {"method": "mobile_phone"}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 404) + + def test_update_registered_user_method_requires_authentication(self): + url = self._get_update_method_url() + response = self.client.post(url, {"method": "mobile_phone"}) + self.assertEqual(response.status_code, 401) + class TestTransactionApi(AcctMixin, ApiTokenMixin, BaseTransactionTestCase): def test_user_radius_usage_view(self): @@ -1619,7 +1756,7 @@ def test_user_radius_usage_view(self): response = self.client.post( auth_url, {"username": "tester", "password": "tester"} ) - authorization = f'Bearer {response.data["key"]}' + authorization = f"Bearer {response.data['key']}" self.assertEqual(response.status_code, 200) with self.subTest("Test user has not used any data"): response = self.client.get(usage_url, HTTP_AUTHORIZATION=authorization) diff --git a/openwisp_radius/tests/test_api/test_rest_token.py b/openwisp_radius/tests/test_api/test_rest_token.py index be26a7fb..e3e553a2 100644 --- a/openwisp_radius/tests/test_api/test_rest_token.py +++ b/openwisp_radius/tests/test_api/test_rest_token.py @@ -229,11 +229,14 @@ def test_unverified_registered_user_different_organization(self): ) registered_user = RegisteredUser.objects.get(user=user, organization=org2) - with self.subTest("Test unverified user without registration method"): + with self.subTest("Test new RegisteredUser has pending_verification method"): + self.assertEqual(registered_user.method, "pending_verification") + + with self.subTest("Test unverified user with pending_verification method"): response = self.client.post(url, user_cred) self.assertEqual(response.status_code, 401) - with self.subTest("Test verified user without registration method"): + with self.subTest("Test verified user with pending_verification method"): registered_user.is_verified = True registered_user.save() response = self.client.post(url, user_cred) From 6e72be30ece6c3b30b2f931aeaed24b676ba0bce Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 20 Apr 2026 17:07:28 +0530 Subject: [PATCH 10/27] [fix] Fixes by @coderabbitai --- docs/user/settings.rst | 5 +-- openwisp_radius/api/views.py | 1 - openwisp_radius/tests/test_admin.py | 32 +++++++++++------- .../tests/test_api/test_freeradius_api.py | 33 ++++++++++--------- .../tests/test_api/test_phone_verification.py | 23 ++++--------- .../tests/test_api/test_rest_token.py | 19 ++++++----- 6 files changed, 57 insertions(+), 56 deletions(-) diff --git a/docs/user/settings.rst b/docs/user/settings.rst index 597a933c..3ee5d040 100644 --- a/docs/user/settings.rst +++ b/docs/user/settings.rst @@ -696,8 +696,9 @@ verification method. The following choices are available by default: - ``mobile_phone``: Mobile phone number :ref:`verification via SMS ` - ``social_login``: :doc:`social login feature ` -- ``pending_verification``: Transitional state used when a user authenticates - to a new organization but has not yet completed verification for that organization. +- ``pending_verification``: Transitional state used when a user + authenticates to a new organization but has not yet completed + verification for that organization. .. note:: diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index bb647df8..a2740e76 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -774,7 +774,6 @@ def post(self, request, *args, **kwargs): }, ) reg_user.is_verified = True - reg_user.method = "mobile_phone" # Update username if phone_number is used as username if user.username == user.phone_number: user.username = phone_token.phone_number diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index 26468c03..344352e2 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -671,16 +671,19 @@ def test_backward_compatible_default_password_reset_url(self): f"admin:{self.app_label_users}_organization_add", ) PASSWORD_RESET_URLS = {"default": default_password_reset_url} - with mock.patch.object( - app_settings, - "DEFAULT_PASSWORD_RESET_URL", - app_settings.get_default_password_reset_url(PASSWORD_RESET_URLS), - ), mock.patch.object( - # The default value is set on project startup, hence - # it also requires mocking. - OrganizationRadiusSettings._meta.get_field("password_reset_url"), - "fallback", - app_settings.DEFAULT_PASSWORD_RESET_URL, + with ( + mock.patch.object( + app_settings, + "DEFAULT_PASSWORD_RESET_URL", + app_settings.get_default_password_reset_url(PASSWORD_RESET_URLS), + ), + mock.patch.object( + # The default value is set on project startup, hence + # it also requires mocking. + OrganizationRadiusSettings._meta.get_field("password_reset_url"), + "fallback", + app_settings.DEFAULT_PASSWORD_RESET_URL, + ), ): response = self.client.get(url) self.assertContains(response, default_password_reset_url) @@ -1418,8 +1421,13 @@ def test_user_admin_shows_multiple_registered_user_records(self): user_url = reverse(f"admin:{User._meta.app_label}_user_change", args=[user.pk]) response = self.client.get(user_url) self.assertEqual(response.status_code, 200) - self.assertContains(response, "id_registered_users-TOTAL_FORMS") - self.assertIn('value="3"', response.rendered_content) + self.assertContains( + response, + ( + '' + ), + ) def test_get_is_verified_user_admin_list(self): unknown = User.objects.first() diff --git a/openwisp_radius/tests/test_api/test_freeradius_api.py b/openwisp_radius/tests/test_api/test_freeradius_api.py index 3a243268..a80f8393 100644 --- a/openwisp_radius/tests/test_api/test_freeradius_api.py +++ b/openwisp_radius/tests/test_api/test_freeradius_api.py @@ -172,7 +172,7 @@ def test_authorize_fail_auth_details_incomplete(self): f"?uuid={str(self.default_org.pk)}", ]: with self.subTest(querystring): - post_url = f'{reverse("radius:authorize")}{querystring}' + post_url = f"{reverse('radius:authorize')}{querystring}" response = self.client.post( post_url, {"username": "tester", "password": "tester"} ) @@ -1309,7 +1309,7 @@ def test_accounting_when_nas_using_pfsense_started(self): self.assertIsNone(response.data) def test_get_authorize_view(self): - url = f'{reverse("radius:authorize")}{self.token_querystring}' + url = f"{reverse('radius:authorize')}{self.token_querystring}" r = self.client.get(url, HTTP_ACCEPT="text/html") self.assertEqual(r.status_code, 405) expected = f'
Date: Wed, 22 Apr 2026 02:31:26 +0530 Subject: [PATCH 11/27] [fix] Fixed choices in UpgradeRegisteredUserSerializer --- openwisp_radius/api/serializers.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 9c98cb1f..52da191a 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -36,7 +36,7 @@ from .. import settings as app_settings from ..base.forms import PasswordResetForm from ..counters.exceptions import SkipCheck -from ..registration import REGISTRATION_METHOD_CHOICES +from ..registration import get_registration_choices from ..utils import ( get_group_checks, get_organization_radius_settings, @@ -571,7 +571,7 @@ class RegisterSerializer( 'verification in its "Organization RADIUS Settings."' ), default="", - choices=REGISTRATION_METHOD_CHOICES, + choices=get_registration_choices(), ) def validate_phone_number(self, phone_number): @@ -767,11 +767,7 @@ def save(self): class UpdateRegisteredUserMethodSerializer(ValidatedModelSerializer): method = serializers.ChoiceField( - choices=[ - choice - for choice in REGISTRATION_METHOD_CHOICES - if choice[0] != "pending_verification" - ], + choices=get_registration_choices(), help_text=_( "The registration method to set for the user. " "Cannot be 'pending_verification'." From 69bff696757f7bd5442a1ac8d1b67dd4dd49456c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 22 Apr 2026 22:40:13 +0530 Subject: [PATCH 12/27] [fix] Fixed tests --- .../monitoring/tests/test_metrics.py | 2 +- openwisp_radius/tests/test_admin.py | 23 ++++++++----------- openwisp_radius/tests/test_api/test_api.py | 16 +++++++------ .../tests/test_api/test_freeradius_api.py | 23 ++++++++----------- .../tests/test_api/test_phone_verification.py | 2 +- .../tests/test_api/test_rest_token.py | 22 ++++++++++++++---- tests/openwisp2/sample_radius/api/views.py | 8 +++++++ 7 files changed, 56 insertions(+), 40 deletions(-) diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index 3e2596f4..e2e911b7 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -21,7 +21,7 @@ @tag("radius_monitoring") class TestMetrics(CreateDeviceMonitoringMixin, BaseTransactionTestCase): - def _read_chart(chart, **kwargs): + def _read_chart(self, chart, **kwargs): return chart.read( additional_query_kwargs={"additional_params": kwargs}, ) diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index 344352e2..68b6de13 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -671,19 +671,16 @@ def test_backward_compatible_default_password_reset_url(self): f"admin:{self.app_label_users}_organization_add", ) PASSWORD_RESET_URLS = {"default": default_password_reset_url} - with ( - mock.patch.object( - app_settings, - "DEFAULT_PASSWORD_RESET_URL", - app_settings.get_default_password_reset_url(PASSWORD_RESET_URLS), - ), - mock.patch.object( - # The default value is set on project startup, hence - # it also requires mocking. - OrganizationRadiusSettings._meta.get_field("password_reset_url"), - "fallback", - app_settings.DEFAULT_PASSWORD_RESET_URL, - ), + with mock.patch.object( + app_settings, + "DEFAULT_PASSWORD_RESET_URL", + app_settings.get_default_password_reset_url(PASSWORD_RESET_URLS), + ), mock.patch.object( + # The default value is set on project startup, hence + # it also requires mocking. + OrganizationRadiusSettings._meta.get_field("password_reset_url"), + "fallback", + app_settings.DEFAULT_PASSWORD_RESET_URL, ): response = self.client.get(url) self.assertContains(response, default_password_reset_url) diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 3acea4aa..81c08312 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -72,11 +72,11 @@ def _get_update_method_url(self, org=None): "radius:update_registered_user_registration_method", args=[org.slug] ) - def _create_pending_verification_user(self): + def _create_pending_verification_user(self, username_suffix=""): user = self._create_user( - username="pendinguser", + username=f"pendinguser{username_suffix}", password="tester", - email="pendinguser@test.com", + email=f"pendinguser{username_suffix}@test.com", ) org2 = self._create_org(name="org2") OrganizationUser.objects.create(user=user, organization=org2) @@ -1636,7 +1636,7 @@ def test_radius_user_group_serializer_without_view_context(self): def test_update_registered_user_method_success(self): user, org2, user_token = self._create_pending_verification_user( - suffix="_success" + username_suffix="_success" ) url = self._get_update_method_url(org2) response = self.client.post( @@ -1651,7 +1651,9 @@ def test_update_registered_user_method_success(self): self.assertEqual(registered_user.is_verified, False) def test_update_registered_user_method_with_valid_methods(self): - user, org2, user_token = self._create_pending_verification_user(suffix="_valid") + user, org2, user_token = self._create_pending_verification_user( + username_suffix="_valid" + ) url = self._get_update_method_url(org2) for method in ["", "manual", "email", "mobile_phone"]: with self.subTest(method=method): @@ -1713,7 +1715,7 @@ def test_update_registered_user_method_404_cases(self): with self.subTest("only_owner_can_update"): user, org2, user_token = self._create_pending_verification_user( - suffix="_owner" + username_suffix="_owner" ) other_user = self._create_user( username="otheruser", password="tester", email="otheruser@test.com" @@ -1729,7 +1731,7 @@ def test_update_registered_user_method_404_cases(self): with self.subTest("invalid_org"): user, _, user_token = self._create_pending_verification_user( - suffix="_invalid_org" + username_suffix="_invalid_org" ) url = reverse( "radius:update_registered_user_registration_method", diff --git a/openwisp_radius/tests/test_api/test_freeradius_api.py b/openwisp_radius/tests/test_api/test_freeradius_api.py index a80f8393..8dca8c95 100644 --- a/openwisp_radius/tests/test_api/test_freeradius_api.py +++ b/openwisp_radius/tests/test_api/test_freeradius_api.py @@ -2242,7 +2242,7 @@ def test_automatic_groupname_account_enabled(self): ) user.radiususergroup_set.set([usergroup1, usergroup2]) self.client.post( - f"{reverse('radius:accounting')}{self.token_querystring}", + f'{reverse("radius:accounting")}{self.token_querystring}', { "status_type": "Start", "session_time": "", @@ -2281,7 +2281,7 @@ def test_multiple_radius_group_with_different_org_and_priority(self): ) user.radiususergroup_set.set([usergroup1, usergroup2]) self.client.post( - f"{reverse('radius:accounting')}{self.token_querystring}", + f'{reverse("radius:accounting")}{self.token_querystring}', { "status_type": "Start", "session_time": "", @@ -2301,7 +2301,7 @@ def test_multiple_radius_group_with_different_org_and_priority(self): def test_mac_authentication_with_no_logging(self, logger): username = "5c:7d:c1:72:a7:3b" self.client.post( - f"{reverse('radius:accounting')}{self.token_querystring}", + f'{reverse("radius:accounting")}{self.token_querystring}', { "status_type": "Start", "session_time": "", @@ -2336,7 +2336,7 @@ def test_account_creation_api_automatic_groupname_disabled(self): groupname="group2", priority=1, username="testgroup2" ) user.radiususergroup_set.set([usergroup1, usergroup2]) - url = f"{reverse('radius:accounting')}{self.token_querystring}" + url = f'{reverse("radius:accounting")}{self.token_querystring}' self.client.post( url, { @@ -2401,15 +2401,12 @@ def test_ip_from_setting_invalid(self): "Request rejected: (localhost) in organization settings or " "settings.py is not a valid IP address. Please contact administrator." ) - with ( - mock.patch( - "openwisp_radius.settings.FREERADIUS_ALLOWED_HOSTS", ["localhost"] - ), - mock.patch.object( - OrganizationRadiusSettings._meta.get_field("freeradius_allowed_hosts"), - "from_db_value", - return_value="localhost", - ), + with mock.patch( + "openwisp_radius.settings.FREERADIUS_ALLOWED_HOSTS", ["localhost"] + ), mock.patch.object( + OrganizationRadiusSettings._meta.get_field("freeradius_allowed_hosts"), + "from_db_value", + return_value="localhost", ): response = self.client.post(reverse("radius:authorize"), self.params) self.assertEqual(response.status_code, 403) diff --git a/openwisp_radius/tests/test_api/test_phone_verification.py b/openwisp_radius/tests/test_api/test_phone_verification.py index 6243a36d..5a13d880 100644 --- a/openwisp_radius/tests/test_api/test_phone_verification.py +++ b/openwisp_radius/tests/test_api/test_phone_verification.py @@ -828,7 +828,7 @@ def _create_user_helper(self, options): r1 = self.client.post( url, content_type="application/json", - HTTP_AUTHORIZATION=f"Bearer {r.data['key']}", + HTTP_AUTHORIZATION=f'Bearer {r.data["key"]}', ) self.assertEqual(r1.status_code, 201) diff --git a/openwisp_radius/tests/test_api/test_rest_token.py b/openwisp_radius/tests/test_api/test_rest_token.py index cdd3f282..6cdb0acc 100644 --- a/openwisp_radius/tests/test_api/test_rest_token.py +++ b/openwisp_radius/tests/test_api/test_rest_token.py @@ -470,11 +470,7 @@ def test_multi_org_phone_verification_flow(self, *args): RegisteredUser.objects.get(user=user, organization=org_a).is_verified, True, ) - - with self.subTest("User is verified in OrgA but not in OrgB"): - self.assertTrue( - RegisteredUser.objects.get(user=user, organization=org_a).is_verified - ) + # Ensure that the user is not registered for OrgB yet self.assertEqual( RegisteredUser.objects.filter(user=user, organization=org_b).count(), 0, @@ -500,6 +496,22 @@ def test_multi_org_phone_verification_flow(self, *args): False, ) + with self.subTest("Update the registration method for OrgB to mobile_phone"): + url = reverse( + "radius:update_registered_user_registration_method", args=[org_b.slug] + ) + response = self.client.post( + url, + {"method": "mobile_phone"}, + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + RegisteredUser.objects.get(user=user, organization=org_b).method, + "mobile_phone", + ) + with self.subTest("Complete phone verification for OrgB"): user_token = Token.objects.get(user=user) phone_token = PhoneToken.objects.create( diff --git a/tests/openwisp2/sample_radius/api/views.py b/tests/openwisp2/sample_radius/api/views.py index 6bb68b99..d5b468ef 100644 --- a/tests/openwisp2/sample_radius/api/views.py +++ b/tests/openwisp2/sample_radius/api/views.py @@ -24,6 +24,9 @@ RadiusUserGroupListCreateView, ) from openwisp_radius.api.views import RegisterView as BaseRegisterView +from openwisp_radius.api.views import ( + UpdateRegisteredUserMethodView as BaseUpdateRegisteredUserMethodView, +) from openwisp_radius.api.views import UserAccountingView as BaseUserAccountingView from openwisp_radius.api.views import UserRadiusUsageView as BaseUserRadiusUsageView from openwisp_radius.api.views import ValidateAuthTokenView as BaseValidateAuthTokenView @@ -104,6 +107,10 @@ class RadiusAccountingView(BaseRadiusAccountingView): pass +class UpdateRegisteredUserMethodView(BaseUpdateRegisteredUserMethodView): + pass + + authorize = AuthorizeView.as_view() postauth = PostAuthView.as_view() accounting = AccountingView.as_view() @@ -126,3 +133,4 @@ class RadiusAccountingView(BaseRadiusAccountingView): radius_group_detail = RadiusGroupDetailView.as_view() radius_user_group_list = RadiusUserGroupListCreateView.as_view() radius_user_group_detail = RadiusUserGroupDetailView.as_view() +update_registered_user_registration_method = UpdateRegisteredUserMethodView.as_view() From 990c037828dabc400793057b3f7a8bf8a20e3221 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 22 Apr 2026 23:36:09 +0530 Subject: [PATCH 13/27] [tests] Fixed tests --- openwisp_radius/integrations/monitoring/tests/test_metrics.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index e2e911b7..e768d89c 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -247,6 +247,7 @@ def test_post_save_radius_accounting_device_not_found(self, mocked_logger): convert_called_station_id feature, but it is not configured properly leaving all called_station_id unconverted. """ + cache.clear() user = self._create_user() reg_user = self._create_registered_user(user=user) options = _RADACCT.copy() From e6b2c4cc8f2e7880f0732d0d3f18871c87d49f4f Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 23 Apr 2026 15:57:58 +0530 Subject: [PATCH 14/27] [fix] Removed re-registering of SocialAppAdmin Since openwisp-users==1.2.1, openwisp-users is responsible for registering SocialAppAdmin. Thus, re-registering the admin class here raises error. --- openwisp_radius/admin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 6880a23c..a057ff4c 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -630,7 +630,7 @@ class Media: # avoid cluttering the admin with too many models, leave only the # minimum required to configure social login and check if it's working if app_settings.SOCIAL_REGISTRATION_CONFIGURED: - from allauth.socialaccount.admin import SocialAccount, SocialApp, SocialAppAdmin + from allauth.socialaccount.admin import SocialAccount class SocialAccountInline(admin.StackedInline): model = SocialAccount @@ -644,7 +644,6 @@ def has_delete_permission(self, request, obj=None): return False UserAdmin.inlines += [SocialAccountInline] - admin.site.register(SocialApp, SocialAppAdmin) if app_settings.USER_ADMIN_RADIUSTOKEN_INLINE: From 4dde44366e9279f80879d6a805d0aa243c93a941 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 23 Apr 2026 18:00:49 +0530 Subject: [PATCH 15/27] [fix] Fixed ValidatePhoneTokenView --- openwisp_radius/api/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index a2740e76..0c568103 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -770,7 +770,6 @@ def post(self, request, *args, **kwargs): defaults={ "is_verified": True, "method": "mobile_phone", - "is_active": True, }, ) reg_user.is_verified = True From 931129efbb9e4d60d23f7d3b9ba3d2801fb8a498 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 24 Apr 2026 17:34:29 +0530 Subject: [PATCH 16/27] [fix] Fixes by @coderabbitai --- openwisp_radius/admin.py | 7 +- openwisp_radius/api/views.py | 21 +++--- .../monitoring/tests/test_metrics.py | 26 +++++-- .../0043_registereduser_add_uuid.py | 29 +++----- openwisp_radius/migrations/__init__.py | 8 +-- openwisp_radius/saml/views.py | 31 ++++---- openwisp_radius/social/views.py | 23 +++--- .../tests/test_api/test_rest_token.py | 71 ++++++++++++++++--- .../0032_registered_user_multitenant.py | 22 +++--- 9 files changed, 145 insertions(+), 93 deletions(-) diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index a057ff4c..78f7dc67 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -538,11 +538,8 @@ class RegisteredUserInline(StackedInline): model = RegisteredUser form = AlwaysHasChangedForm extra = 0 - readonly_fields = ( - "organization", - "modified", - ) - fields = ("organization", "method", "is_verified", "modified") + readonly_fields = ("modified",) + fields = ("method", "is_verified", "modified") def has_delete_permission(self, request, obj=None): return False diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 0c568103..dd674a04 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -11,8 +11,8 @@ from django.contrib.sites.shortcuts import get_current_site from django.core.cache import cache from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction from django.db.models import Q -from django.db.utils import IntegrityError from django.http import Http404, HttpResponse from django.utils import timezone from django.utils.decorators import method_decorator @@ -339,16 +339,15 @@ def validate_membership(self, user): self.organization, "registration_enabled" ): try: - org_user = OrganizationUser( - user=user, organization=self.organization - ) - org_user.full_clean() - org_user.save() - RegisteredUser.objects.get_or_create( - user=user, - organization=self.organization, - defaults={"method": "pending_verification"}, - ) + with transaction.atomic(): + OrganizationUser.objects.get_or_create( + user=user, organization=self.organization + ) + RegisteredUser.objects.get_or_create( + user=user, + organization=self.organization, + defaults={"method": "pending_verification"}, + ) except ValidationError as error: raise serializers.ValidationError( {"non_field_errors": error.message_dict.pop("__all__")} diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index e768d89c..414da150 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -26,6 +26,18 @@ def _read_chart(self, chart, **kwargs): additional_query_kwargs={"additional_params": kwargs}, ) + def _assert_pending_verification_excluded(self, points): + pending_verification_traces = [ + trace_points + for trace_name, trace_points in points["traces"] + if trace_name == "pending_verification" + ] + self.assertEqual(pending_verification_traces, []) + self.assertNotIn( + "pending_verification", + points.get("summary", {}), + ) + def _create_registered_user(self, **kwargs): options = { "is_verified": False, @@ -517,11 +529,17 @@ def test_pending_verification_excluded_from_metrics(self): write_user_registration_metrics.delay() user_signup_chart = user_signup_metric.chart_set.first() - all_points = self._read_chart(user_signup_chart, organization_id=[str(org.pk)]) - self.assertEqual(len(all_points["traces"]), 0) + org_points = self._read_chart(user_signup_chart, organization_id=[str(org.pk)]) + all_points = self._read_chart(user_signup_chart, organization_id=["__all__"]) + self._assert_pending_verification_excluded(org_points) + self._assert_pending_verification_excluded(all_points) total_user_signup_chart = total_user_signup_metric.chart_set.first() - all_points = self._read_chart( + org_points = self._read_chart( total_user_signup_chart, organization_id=[str(org.pk)] ) - self.assertEqual(len(all_points["traces"]), 0) + all_points = self._read_chart( + total_user_signup_chart, organization_id=["__all__"] + ) + self._assert_pending_verification_excluded(org_points) + self._assert_pending_verification_excluded(all_points) diff --git a/openwisp_radius/migrations/0043_registereduser_add_uuid.py b/openwisp_radius/migrations/0043_registereduser_add_uuid.py index 5df26656..a516c846 100644 --- a/openwisp_radius/migrations/0043_registereduser_add_uuid.py +++ b/openwisp_radius/migrations/0043_registereduser_add_uuid.py @@ -1,6 +1,5 @@ import uuid -import django import django.db.models.deletion import django.utils.timezone import model_utils.fields @@ -8,16 +7,7 @@ from django.conf import settings from django.db import migrations, models -from openwisp_radius.registration import ( - REGISTRATION_METHOD_CHOICES, - get_registration_choices, -) - -from . import ( - REGISTERED_USER_ORGANIZATION_HELP_TEXT, - copy_registered_users_ctcr_forward, - copy_registered_users_ctcr_reverse, -) +from . import copy_registered_users_ctcr_forward, copy_registered_users_ctcr_reverse def copy_registered_users_forward(apps, schema_editor): @@ -62,7 +52,11 @@ class Migration(migrations.Migration): name="organization", field=models.ForeignKey( blank=True, - help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, + help_text=( + "The organization this registration info belongs to. " + "If null, applies to all orgs without specific" + " requirements." + ), null=True, related_name="registered_users", on_delete=django.db.models.deletion.CASCADE, @@ -88,11 +82,6 @@ class Migration(migrations.Migration): "method", models.CharField( blank=True, - choices=( - REGISTRATION_METHOD_CHOICES - if django.VERSION < (5, 0) - else get_registration_choices - ), default="", help_text=( "users can sign up in different ways, some " @@ -135,7 +124,11 @@ class Migration(migrations.Migration): "organization", models.ForeignKey( blank=True, - help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, + help_text=( + "The organization this registration info belongs" + " to. If null, applies to all orgs without" + " specific requirements." + ), null=True, on_delete=django.db.models.deletion.CASCADE, related_name="+", diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index c2123c9e..c4ca79ac 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -10,10 +10,6 @@ from ..utils import create_default_groups BATCH_SIZE = 1000 -REGISTERED_USER_ORGANIZATION_HELP_TEXT = ( - "The organization this registration info belongs to. " - "If null, applies to all orgs without specific requirements." -) def get_swapped_model(apps, app_name, model_name): @@ -100,7 +96,7 @@ def copy_registered_users_ctcr_reverse( ) queryset = RegisteredUserNew.objects.annotate( method_priority=method_priority - ).order_by("user_id", "-is_verified", "-method_priority", "pk") + ).order_by("user_id", "-is_verified", "-method_priority", "-modified") for registered_user in queryset.iterator(chunk_size=BATCH_SIZE): if registered_user.user_id == previous_user_id: continue @@ -212,7 +208,7 @@ def migrate_registered_users_multitenant_reverse( organization__isnull=False, ) .annotate(method_priority=method_priority) - .order_by("user_id", "-is_verified", "-method_priority", "pk") + .order_by("user_id", "-is_verified", "-method_priority", "-modified") ) to_create = [] diff --git a/openwisp_radius/saml/views.py b/openwisp_radius/saml/views.py index ca3fab38..2e953518 100644 --- a/openwisp_radius/saml/views.py +++ b/openwisp_radius/saml/views.py @@ -9,6 +9,7 @@ from django.contrib.auth import get_user_model, logout from django.contrib.auth.mixins import LoginRequiredMixin from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError +from django.db import transaction from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.generic import UpdateView @@ -67,23 +68,21 @@ def post_login_hook(self, request, user, session_info): org = self.get_organization_from_relay_state() is_member = user.is_member(org) # add user to organization - if not is_member: - orgUser = OrganizationUser(organization=org, user=user) - orgUser.full_clean() - orgUser.save() - try: - user.registered_users.get(organization=org) - except RegisteredUser.DoesNotExist: - registered_user = RegisteredUser( + with transaction.atomic(): + if not is_member: + orgUser = OrganizationUser(organization=org, user=user) + orgUser.full_clean() + orgUser.save() + registered_user, created = RegisteredUser.objects.get_or_create( user=user, organization=org, - method="saml", - is_verified=app_settings.SAML_IS_VERIFIED, + defaults={ + "method": "saml", + "is_verified": app_settings.SAML_IS_VERIFIED, + }, ) - registered_user.full_clean() - registered_user.save() - # The user is just created, it will not have an email address - if user.email: + if created and user.email: + # The user is just created, it will not have an email address try: email_address = EmailAddress( user=user, email=user.email, primary=True, verified=True @@ -92,8 +91,8 @@ def post_login_hook(self, request, user, session_info): email_address.save() except ValidationError: logger.exception( - f'Failed email validation for "{user}"' - " during SAML user creation" + f'Failed email validation for "{user}" during' + " SAML user creation" ) def customize_relay_state(self, relay_state): diff --git a/openwisp_radius/social/views.py b/openwisp_radius/social/views.py index ac132611..c94f6b63 100644 --- a/openwisp_radius/social/views.py +++ b/openwisp_radius/social/views.py @@ -1,5 +1,6 @@ import swapper from django.core.exceptions import PermissionDenied +from django.db import transaction from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ @@ -42,21 +43,19 @@ def authorize(self, request, org, *args, **kwargs): user = request.user is_member = user.is_member(org) # add user to organization - if not is_member: - orgUser = OrganizationUser(organization=org, user=user) - orgUser.full_clean() - orgUser.save() - try: - user.registered_users.get(organization=org) - except RegisteredUser.DoesNotExist: - registered_user = RegisteredUser( + with transaction.atomic(): + if not is_member: + orgUser = OrganizationUser(organization=org, user=user) + orgUser.full_clean() + orgUser.save() + registered_user, created = RegisteredUser.objects.get_or_create( user=user, organization=org, - method="social_login", - is_verified=False, + defaults={"method": "social_login", "is_verified": False}, ) - registered_user.full_clean() - registered_user.save() + if created: + registered_user.full_clean() + registered_user.save() def get_redirect_url(self, request, organization): """ diff --git a/openwisp_radius/tests/test_api/test_rest_token.py b/openwisp_radius/tests/test_api/test_rest_token.py index 6cdb0acc..ae054525 100644 --- a/openwisp_radius/tests/test_api/test_rest_token.py +++ b/openwisp_radius/tests/test_api/test_rest_token.py @@ -2,6 +2,7 @@ import swapper from django.contrib.auth import get_user_model +from django.db import IntegrityError from django.urls import reverse from django.utils.timezone import localtime, now, timedelta from freezegun import freeze_time @@ -13,7 +14,7 @@ from ... import settings as app_settings from ...utils import load_model from .. import _TEST_DATE -from ..mixins import ApiTokenMixin, BaseTestCase +from ..mixins import ApiTokenMixin, BaseTestCase, BaseTransactionTestCase RadiusToken = load_model("RadiusToken") RegisteredUser = load_model("RegisteredUser") @@ -137,14 +138,19 @@ def test_user_auth_token_different_organization(self): response = self.client.post( url, {"username": "tester", "password": "tester"} ) - self.assertEqual(response.status_code, 400) - expected_response = { - "non_field_errors": [ - "Organization user with this User and " - "Organization already exists." - ] - } - self.assertEqual(response.data, expected_response) + self.assertEqual(response.status_code, 200) + self.assertEqual( + OrganizationUser.objects.filter( + user__username="tester", organization=org2 + ).count(), + 1, + ) + self.assertEqual( + RegisteredUser.objects.filter( + user__username="tester", organization=org2 + ).count(), + 1, + ) @capture_any_output() def test_user_auth_token_different_organization_registration_settings(self): @@ -298,6 +304,53 @@ def test_user_auth_token_password_expired(self): self.assertEqual(response.data["password_expired"], True) +class TestApiUserTokenTransactions(ApiTokenMixin, BaseTransactionTestCase): + @capture_any_output() + def test_user_auth_token_integrity_error_fallback(self): + org_user = self._get_org_user() + org2 = self._create_org(name="org2") + OrganizationRadiusSettings.objects.create( + organization=org2, needs_identity_verification=False + ) + OrganizationUser.objects.create(user=org_user.user, organization=org2) + RegisteredUser.objects.create( + user=org_user.user, + organization=org2, + method="pending_verification", + ) + url = reverse("radius:user_auth_token", args=[org2.slug]) + + with ( + mock.patch.object( + OrganizationUser.objects, + "get_or_create", + side_effect=IntegrityError, + ), + mock.patch.object( + RegisteredUser.objects, + "get_or_create", + side_effect=IntegrityError, + ), + ): + response = self.client.post( + url, {"username": org_user.user.username, "password": "tester"} + ) + self.assertEqual(response.status_code, 200) + self.assertIn("key", response.data) + self.assertEqual( + OrganizationUser.objects.filter( + user=org_user.user, organization=org2 + ).count(), + 1, + ) + self.assertEqual( + RegisteredUser.objects.filter( + user=org_user.user, organization=org2 + ).count(), + 1, + ) + + class TestApiValidateToken(ApiTokenMixin, BaseTestCase): def _get_url(self): return reverse("radius:validate_auth_token", args=[self.default_org.slug]) diff --git a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py index 2c7ce45c..a53e399f 100644 --- a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py +++ b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py @@ -9,16 +9,11 @@ from django.db import migrations, models from openwisp_radius.migrations import ( - REGISTERED_USER_ORGANIZATION_HELP_TEXT, copy_registered_users_ctcr_forward, copy_registered_users_ctcr_reverse, migrate_registered_users_multitenant_forward, migrate_registered_users_multitenant_reverse, ) -from openwisp_radius.registration import ( - REGISTRATION_METHOD_CHOICES, - get_registration_choices, -) def copy_registered_users_forward(apps, schema_editor): @@ -92,11 +87,6 @@ class Migration(migrations.Migration): "method", models.CharField( blank=True, - choices=( - REGISTRATION_METHOD_CHOICES - if django.VERSION < (5, 0) - else get_registration_choices - ), default="", help_text=( "users can sign up in different ways, some " @@ -139,7 +129,11 @@ class Migration(migrations.Migration): "organization", models.ForeignKey( blank=True, - help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, + help_text=( + "The organization this registration info belongs" + " to. If null, applies to all orgs without" + " specific requirements." + ), null=True, on_delete=django.db.models.deletion.CASCADE, related_name="+", @@ -190,7 +184,11 @@ class Migration(migrations.Migration): name="organization", field=models.ForeignKey( blank=True, - help_text=REGISTERED_USER_ORGANIZATION_HELP_TEXT, + help_text=( + "The organization this registration info belongs" + " to. If null, applies to all orgs without" + " specific requirements." + ), null=True, related_name="registered_users", on_delete=django.db.models.deletion.CASCADE, From 0f904c37069ac0a981c800243bb0add74c872eee Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 24 Apr 2026 18:01:29 +0530 Subject: [PATCH 17/27] [fix] Fixes by @coderabbitai --- openwisp_radius/migrations/__init__.py | 97 ++++++++++++----- openwisp_radius/tests/test_migrations.py | 129 +++++++++++++++++++++++ 2 files changed, 197 insertions(+), 29 deletions(-) create mode 100644 openwisp_radius/tests/test_migrations.py diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index c4ca79ac..580879a0 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -35,12 +35,45 @@ def _flush_bulk_create(model, objects, batch_size=BATCH_SIZE): objects.clear() +def _flush_bulk_update(model, objects, fields, batch_size=BATCH_SIZE): + if objects: + model.objects.bulk_update(objects, fields=fields, batch_size=batch_size) + objects.clear() + + def _registered_user_extra_kwargs(registered_user, extra_fields=()): return { field_name: getattr(registered_user, field_name) for field_name in extra_fields } +def _registered_user_method_priority_case(): + # Strong methods (anything that is not '' or 'email') must rank above the + # weak fallbacks so rollback restores the strongest verification state. + return Case( + When(method="", then=Value(0)), + When(method="email", then=Value(1)), + default=Value(2), + output_field=IntegerField(), + ) + + +def _registered_user_method_priority(registered_user): + if registered_user.method == "": + return 0 + if registered_user.method == "email": + return 1 + return 2 + + +def _registered_user_strength(registered_user): + return ( + int(registered_user.is_verified), + _registered_user_method_priority(registered_user), + registered_user.modified, + ) + + def copy_registered_users_ctcr_forward( apps, schema_editor, @@ -88,12 +121,7 @@ def copy_registered_users_ctcr_reverse( # Annotate each row with an explicit verification priority so that stronger # methods (anything that is not '' or 'email') sort before weaker ones. # Lexical ordering of 'method' would place '' first, picking the weakest. - method_priority = Case( - When(method="", then=Value(0)), - When(method="email", then=Value(1)), - default=Value(2), - output_field=IntegerField(), - ) + method_priority = _registered_user_method_priority_case() queryset = RegisteredUserNew.objects.annotate( method_priority=method_priority ).order_by("user_id", "-is_verified", "-method_priority", "-modified") @@ -187,21 +215,17 @@ def migrate_registered_users_multitenant_reverse( for user_id_batch in _batched_iterator( user_ids_qs.iterator(chunk_size=BATCH_SIZE), BATCH_SIZE ): - existing_globals = set( - RegisteredUser.objects.filter( + existing_globals = { + registered_user.user_id: registered_user + for registered_user in RegisteredUser.objects.filter( user_id__in=user_id_batch, organization__isnull=True, - ).values_list("user_id", flat=True) - ) + ) + } # Annotate each row with an explicit verification priority so that stronger # methods (anything that is not '' or 'email') sort before weaker ones. # Lexical ordering of 'method' would place '' first, picking the weakest. - method_priority = Case( - When(method="", then=Value(0)), - When(method="email", then=Value(1)), - default=Value(2), - output_field=IntegerField(), - ) + method_priority = _registered_user_method_priority_case() org_records = ( RegisteredUser.objects.filter( user_id__in=user_id_batch, @@ -212,28 +236,43 @@ def migrate_registered_users_multitenant_reverse( ) to_create = [] + to_update = [] to_delete_pks = [] current_user_id = None + update_fields = ["is_verified", "method", "modified", *extra_fields] for registered_user in org_records.iterator(chunk_size=BATCH_SIZE): - to_delete_pks.append(registered_user.pk) if registered_user.user_id == current_user_id: + to_delete_pks.append(registered_user.pk) continue current_user_id = registered_user.user_id - if registered_user.user_id in existing_globals: - continue - restored = RegisteredUser( - id=uuid.uuid4(), - user_id=registered_user.user_id, - organization=None, - is_verified=registered_user.is_verified, - method=registered_user.method, - **_registered_user_extra_kwargs(registered_user, extra_fields), - ) - restored.modified = registered_user.modified - to_create.append(restored) + existing_global = existing_globals.get(registered_user.user_id) + if existing_global is None: + restored = RegisteredUser( + id=uuid.uuid4(), + user_id=registered_user.user_id, + organization=None, + is_verified=registered_user.is_verified, + method=registered_user.method, + **_registered_user_extra_kwargs(registered_user, extra_fields), + ) + restored.modified = registered_user.modified + to_create.append(restored) + elif _registered_user_strength(registered_user) > _registered_user_strength( + existing_global + ): + existing_global.is_verified = registered_user.is_verified + existing_global.method = registered_user.method + existing_global.modified = registered_user.modified + for field_name, value in _registered_user_extra_kwargs( + registered_user, extra_fields + ).items(): + setattr(existing_global, field_name, value) + to_update.append(existing_global) + to_delete_pks.append(registered_user.pk) _flush_bulk_create(RegisteredUser, to_create) + _flush_bulk_update(RegisteredUser, to_update, fields=update_fields) if to_delete_pks: RegisteredUser.objects.filter(pk__in=to_delete_pks).delete() diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py new file mode 100644 index 00000000..1157bf1a --- /dev/null +++ b/openwisp_radius/tests/test_migrations.py @@ -0,0 +1,129 @@ +import swapper +from django.apps.registry import apps +from django.utils import timezone + +from ..migrations import migrate_registered_users_multitenant_reverse +from ..utils import load_model +from .mixins import BaseTestCase + +RegisteredUser = load_model("RegisteredUser") +Organization = swapper.load_model("openwisp_users", "Organization") +User = swapper.load_model("auth", "User") + + +class TestMigrations(BaseTestCase): + def test_multitenant_reverse_updates_weaker_existing_global(self): + """ + Test that during migration rollback, a weaker existing global + RegisteredUser is updated with data from a stronger org-scoped + RegisteredUser instead of being left unchanged. + """ + user = self._create_user(username="rollback-stronger") + org1 = self._create_org(name="rollback-org-1", slug="rollback-org-1") + org2 = self._create_org(name="rollback-org-2", slug="rollback-org-2") + modified_base = timezone.now() + + # Create a weaker existing global (method="email") + existing_global = RegisteredUser.objects.create( + user=user, + organization=None, + is_verified=True, + method="email", + ) + RegisteredUser.objects.filter(pk=existing_global.pk).update( + modified=modified_base + ) + existing_global.refresh_from_db() + + # Create org-scoped email (same strength as global but newer) + org_email = RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=True, + method="email", + ) + RegisteredUser.objects.filter(pk=org_email.pk).update( + modified=modified_base + timezone.timedelta(minutes=10) + ) + org_email.refresh_from_db() + + # Create org-scoped mobile (strongest due to method priority) + org_mobile = RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=True, + method="mobile_phone", + ) + RegisteredUser.objects.filter(pk=org_mobile.pk).update( + modified=modified_base - timezone.timedelta(minutes=10) + ) + org_mobile.refresh_from_db() + + # Rollback: should migrate strongest org-scoped (mobile_phone) to global + migrate_registered_users_multitenant_reverse( + apps, None, app_label="openwisp_radius" + ) + + existing_global.refresh_from_db() + self.assertIsNone(existing_global.organization) + self.assertEqual(existing_global.method, "mobile_phone") + self.assertTrue(existing_global.is_verified) + self.assertEqual(existing_global.modified, org_mobile.modified) + self.assertEqual( + RegisteredUser.objects.filter( + user=user, organization__isnull=False + ).count(), + 0, + ) + + def test_multitenant_reverse_keeps_stronger_existing_global(self): + """ + Test that during migration rollback, if an existing global + RegisteredUser is stronger than all org-scoped candidates, + it is left unchanged and org-scoped rows are still cleaned up. + """ + user = self._create_user(username="rollback-global-wins") + org = self._create_org(name="rollback-org-3", slug="rollback-org-3") + modified_base = timezone.now() + + # Create a stronger existing global (method="mobile_phone", newer timestamp) + existing_global = RegisteredUser.objects.create( + user=user, + organization=None, + is_verified=True, + method="mobile_phone", + ) + RegisteredUser.objects.filter(pk=existing_global.pk).update( + modified=modified_base + timezone.timedelta(minutes=10) + ) + existing_global.refresh_from_db() + + # Create weaker org-scoped (method="social_login", older timestamp) + org_specific = RegisteredUser.objects.create( + user=user, + organization=org, + is_verified=True, + method="social_login", + ) + RegisteredUser.objects.filter(pk=org_specific.pk).update(modified=modified_base) + org_specific.refresh_from_db() + + # Rollback: global should remain unchanged (stronger), org-scoped deleted + migrate_registered_users_multitenant_reverse( + apps, None, app_label="openwisp_radius" + ) + + existing_global.refresh_from_db() + self.assertIsNone(existing_global.organization) + self.assertEqual(existing_global.method, "mobile_phone") + self.assertTrue(existing_global.is_verified) + self.assertEqual( + existing_global.modified, + modified_base + timezone.timedelta(minutes=10), + ) + self.assertEqual( + RegisteredUser.objects.filter( + user=user, organization__isnull=False + ).count(), + 0, + ) From fca02670fd896563e543aeb60a83f3a851263bb8 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 24 Apr 2026 19:33:25 +0530 Subject: [PATCH 18/27] [fix] Fixes by @coderabbitai --- openwisp_radius/api/serializers.py | 4 ++++ openwisp_radius/api/views.py | 3 ++- openwisp_radius/base/models.py | 1 + openwisp_radius/integrations/monitoring/tasks.py | 10 +++++++--- openwisp_radius/migrations/__init__.py | 4 ---- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 52da191a..5fba1c00 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -778,6 +778,10 @@ class Meta: model = RegisteredUser fields = ["method"] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["method"].choices = get_registration_choices() + def validate_method(self, value): if value == "pending_verification": raise serializers.ValidationError( diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index dd674a04..46a185cb 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -772,6 +772,7 @@ def post(self, request, *args, **kwargs): }, ) reg_user.is_verified = True + reg_user.method = "mobile_phone" # Update username if phone_number is used as username if user.username == user.phone_number: user.username = phone_token.phone_number @@ -779,7 +780,7 @@ def post(self, request, *args, **kwargs): # we can write it to the user field user.phone_number = phone_token.phone_number user.save() - reg_user.save() + reg_user.save(update_fields=["is_verified", "method"]) # delete any radius token cache key if present cache.delete(f"rt-{phone_token.phone_number}") return Response(None, status=200) diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index 41d428c7..51de4076 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1071,6 +1071,7 @@ def save_user(self, user): not created and self.organization.radius_settings.needs_identity_verification ): + registered_user.method = "manual" registered_user.is_verified = True registered_user.save() self.users.add(user) diff --git a/openwisp_radius/integrations/monitoring/tasks.py b/openwisp_radius/integrations/monitoring/tasks.py index e4e4f8a0..b3a7355c 100644 --- a/openwisp_radius/integrations/monitoring/tasks.py +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -188,11 +188,15 @@ def post_save_radiusaccounting( ): registration_method = ( RegisteredUser.objects.only("method") - .filter(user__username=username) - .filter(Q(organization_id=organization_id) | Q(organization__isnull=True)) - .order_by("-organization_id") + .filter(user__username=username, organization_id=organization_id) .first() ) + if registration_method is None: + registration_method = ( + RegisteredUser.objects.only("method") + .filter(user__username=username, organization__isnull=True) + .first() + ) if registration_method is None: logger.info( f'RegisteredUser object not found for "{username}".' diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index 580879a0..d907949b 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -171,12 +171,10 @@ def migrate_registered_users_multitenant_forward( ) to_create = [] - to_delete_pks = [] for registered_user in batch: organization_ids = sorted(memberships.get(registered_user.user_id, ())) if not organization_ids: continue - to_delete_pks.append(registered_user.pk) extra_kwargs = _registered_user_extra_kwargs(registered_user, extra_fields) for organization_id in organization_ids: pair = (registered_user.user_id, organization_id) @@ -195,8 +193,6 @@ def migrate_registered_users_multitenant_forward( to_create.append(copied) _flush_bulk_create(RegisteredUser, to_create) - if to_delete_pks: - RegisteredUser.objects.filter(pk__in=to_delete_pks).delete() def migrate_registered_users_multitenant_reverse( From 3887c2f59fbc1a6da4420504f4e4ed18668f0948 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 27 Apr 2026 18:50:23 +0530 Subject: [PATCH 19/27] [fix] Made requested changes --- openwisp_radius/api/freeradius_views.py | 52 +++++++---- openwisp_radius/api/views.py | 15 +-- openwisp_radius/base/models.py | 17 ++-- .../integrations/monitoring/tasks.py | 5 +- .../monitoring/tests/test_metrics.py | 92 ++++++++++++++++++- ...registered_user_multitenant_constraints.py | 6 ++ openwisp_radius/saml/backends.py | 43 +++++---- openwisp_radius/social/views.py | 2 +- .../tests/test_api/test_freeradius_api.py | 71 +++++++++++--- .../tests/test_users_integration.py | 2 +- 10 files changed, 229 insertions(+), 76 deletions(-) diff --git a/openwisp_radius/api/freeradius_views.py b/openwisp_radius/api/freeradius_views.py index b59cd59e..25404e83 100644 --- a/openwisp_radius/api/freeradius_views.py +++ b/openwisp_radius/api/freeradius_views.py @@ -7,7 +7,7 @@ from django.contrib.auth.models import AnonymousUser from django.core.cache import cache from django.db import IntegrityError -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as filters from django_filters.rest_framework import DjangoFilterBackend @@ -57,6 +57,7 @@ RadiusToken = load_model("RadiusToken") RadiusAccounting = load_model("RadiusAccounting") +RegisteredUser = load_model("RegisteredUser") OrganizationRadiusSettings = load_model("OrganizationRadiusSettings") OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") Organization = swapper.load_model("openwisp_users", "Organization") @@ -405,28 +406,45 @@ def _check_counters(self, data, user, group, group_checks): def _get_user_query_conditions(self, request): is_active = Q(is_active=True) needs_verification = self._needs_identity_verification({"pk": request._auth}) - # if no identity verification enabled for this org, - # just ensure user is active if not needs_verification: return is_active organization_id = request._auth - org_or_global = Q(registered_users__organization_id=organization_id) | Q( - registered_users__organization__isnull=True - ) - is_verified = Q(registered_users__is_verified=True) & org_or_global AUTHORIZE_UNVERIFIED = registration.AUTHORIZE_UNVERIFIED - # and no method should authorize unverified users - # ensure user is active AND verified + # Use subqueries to ensure org-specific records take precedence over + # global (organization=NULL) records. + # A JOIN-based filter would allow a user to pass if ANY registered_users + # row matched, causing a bypass when a global verified record coexisted + # with an org-specific unverified record. + # + # Strategy: check if org-specific record exists and satisfies criteria; + # if not, fall back to checking the global record. This matches the + # behavior in api/utils.py:IDVerificationHelper.is_identity_verified_strong. + org_specific = RegisteredUser.objects.filter( + user=OuterRef("pk"), + organization_id=organization_id, + ) + global_only = RegisteredUser.objects.filter( + user=OuterRef("pk"), + organization_id__isnull=True, + ) + + # is_verified: user passes if org-specific record is verified, or if + # no org-specific record exists and the global record is verified. + has_org_verified = Exists(org_specific.filter(is_verified=True)) + has_global_verified = Exists(global_only.filter(is_verified=True)) + no_org_specific = ~Exists(org_specific.values("pk")) + is_verified = has_org_verified | (no_org_specific & has_global_verified) + if not AUTHORIZE_UNVERIFIED: return is_active & is_verified - # in case some methods are allowed to authorize unverified users - # ensure user is active AND - # (user is verified OR user uses one of these methods) - else: - authorize_unverified = ( - Q(registered_users__method__in=AUTHORIZE_UNVERIFIED) & org_or_global - ) - return is_active & (is_verified | authorize_unverified) + + # authorize_unverified: user passes if org-specific record uses a + # special method, or if no org-specific record exists and the global + # record uses a special method. + has_org_special = Exists(org_specific.filter(method__in=AUTHORIZE_UNVERIFIED)) + has_global_special = Exists(global_only.filter(method__in=AUTHORIZE_UNVERIFIED)) + authorize_unverified = has_org_special | (no_org_specific & has_global_special) + return is_active & (is_verified | authorize_unverified) def authenticate_user(self, request, user, password): """ diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 46a185cb..5216d8b3 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -855,16 +855,11 @@ class UpdateRegisteredUserMethodView(DispatchOrgMixin, GenericAPIView): ) def post(self, request, slug): user = request.user - try: - reg_user = get_object_or_404( - RegisteredUser, - user_id=user.pk, - organization=self.organization, - ) - except RegisteredUser.DoesNotExist: - raise NotFound( - _("RegisteredUser not found for this user and organization.") - ) + reg_user = get_object_or_404( + RegisteredUser, + user_id=user.pk, + organization=self.organization, + ) serializer = self.get_serializer( instance=reg_user, data=request.data, partial=True ) diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index 51de4076..eeb1f0cb 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1683,25 +1683,20 @@ class Meta: models.UniqueConstraint( fields=["user", "organization"], name="unique_registered_user_per_org", + violation_error_message=_( + "A registration record already exists for this user/organization." + ), ), models.UniqueConstraint( fields=["user"], condition=Q(organization__isnull=True), name="unique_global_registered_user", + violation_error_message=_( + "A registration record already exists for this user/organization." + ), ), ] - def clean(self): - super().clean() - Model = self._meta.model - qs = Model.objects.filter(user=self.user, organization=self.organization) - if self.pk: - qs = qs.exclude(pk=self.pk) - if qs.exists(): - raise ValidationError( - _("A registration record already exists for this user/organization.") - ) - @classmethod def get_or_create_for_user_and_org(cls, user, organization, defaults=None): defaults = defaults or {} diff --git a/openwisp_radius/integrations/monitoring/tasks.py b/openwisp_radius/integrations/monitoring/tasks.py index b3a7355c..7a2375cf 100644 --- a/openwisp_radius/integrations/monitoring/tasks.py +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -118,6 +118,7 @@ def _write_user_signup_metrics_for_orgs(metric_key): # count of users who registered with that organization and method. registered_users_query = RegisteredUser.objects.exclude( user__openwisp_users_organizationuser__created__gt=end_time, + method="pending_verification", ) if metric_key == "user_signups": @@ -147,8 +148,6 @@ def _write_user_signup_metrics_for_orgs(metric_key): for org_id, registration_method, count in registered_users: registration_method = clean_registration_method(registration_method) - if registration_method is None: - continue if registration_method == "unspecified": count += users_without_registereduser.get(org_id, 0) metric = get_metric_func( @@ -206,6 +205,8 @@ def post_save_radiusaccounting( else: registration_method = registration_method.method registration_method = clean_registration_method(registration_method) + if registration_method is None: + registration_method = "unspecified" device_lookup = Q(mac_address__iexact=called_station_id.replace("-", ":")) extra_tags = { "method": registration_method, diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index 414da150..fe19a852 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -276,7 +276,6 @@ def test_post_save_radius_accounting_device_not_found(self, mocked_logger): options["stop_time"] = options["start_time"] # Remove calls for user registration from mocked logger mocked_logger.reset_mock() - self._create_radius_accounting(**options) self.assertEqual( self.metric_model.objects.filter( @@ -390,6 +389,97 @@ def test_post_save_radius_accounting_registereduser_not_found(self, mocked_logge ' The metric will be written with "unspecified" registration method!' ) + def test_post_save_radiusaccounting_pending_verification(self): + """ + Test that when a user has a RegisteredUser with method="pending_verification", + the metric is written with "unspecified" instead of None. + """ + user = self._create_user() + self._create_registered_user(user=user, method="pending_verification") + device = self._create_device() + device_loc = self._create_device_location( + content_object=device, + location=self._create_location(organization=device.organization), + ) + options = _RADACCT.copy() + options.update( + { + "unique_id": "pending_001", + "username": user.username, + "called_station_id": device.mac_address.replace("-", ":").upper(), + "calling_station_id": "00:00:00:00:00:00", + "input_octets": "8000000000", + "output_octets": "9000000000", + } + ) + options["stop_time"] = options["start_time"] + self._create_radius_accounting(**options) + self.assertEqual( + self.metric_model.objects.filter( + configuration="radius_acc", + name="RADIUS Accounting", + key="radius_acc", + object_id=str(device.id), + content_type=ContentType.objects.get_for_model(self.device_model), + extra_tags={ + "called_station_id": device.mac_address, + "calling_station_id": sha1_hash("00:00:00:00:00:00"), + "location_id": str(device_loc.location.id), + "method": "unspecified", + "organization_id": str(self.default_org.id), + }, + ).count(), + 1, + ) + + def test_post_save_radiusaccounting_org_specific_takes_precedence_over_global( + self, + ): + """ + Test that when a user has both a global (organization=None) and org-specific + RegisteredUser, the org-specific one takes precedence. + """ + user = self._create_user() + self._create_registered_user(user=user, organization=None, method="email") + self._create_registered_user( + user=user, organization=self.default_org, method="mobile_phone" + ) + device = self._create_device() + device_loc = self._create_device_location( + content_object=device, + location=self._create_location(organization=device.organization), + ) + options = _RADACCT.copy() + options.update( + { + "unique_id": "org_spec_001", + "username": user.username, + "called_station_id": device.mac_address.replace("-", ":").upper(), + "calling_station_id": "00:00:00:00:00:00", + "input_octets": "8000000000", + "output_octets": "9000000000", + } + ) + options["stop_time"] = options["start_time"] + self._create_radius_accounting(**options) + self.assertEqual( + self.metric_model.objects.filter( + configuration="radius_acc", + name="RADIUS Accounting", + key="radius_acc", + object_id=str(device.id), + content_type=ContentType.objects.get_for_model(self.device_model), + extra_tags={ + "called_station_id": device.mac_address, + "calling_station_id": sha1_hash("00:00:00:00:00:00"), + "location_id": str(device_loc.location.id), + "method": "mobile_phone", + "organization_id": str(self.default_org.id), + }, + ).count(), + 1, + ) + def test_write_user_registration_metrics(self): from ..tasks import write_user_registration_metrics diff --git a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py index af8ad357..2f3af8f4 100644 --- a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py +++ b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py @@ -12,6 +12,9 @@ class Migration(migrations.Migration): constraint=models.UniqueConstraint( fields=["user", "organization"], name="unique_registered_user_per_org", + violation_error_message=( + "A registration record already exists for this user/organization." + ), ), ), migrations.AddConstraint( @@ -20,6 +23,9 @@ class Migration(migrations.Migration): fields=["user"], condition=models.Q(organization__isnull=True), name="unique_global_registered_user", + violation_error_message=( + "A registration record already exists for this user/organization." + ), ), ), ] diff --git a/openwisp_radius/saml/backends.py b/openwisp_radius/saml/backends.py index bf471870..3c55a657 100644 --- a/openwisp_radius/saml/backends.py +++ b/openwisp_radius/saml/backends.py @@ -1,4 +1,3 @@ -from django.core.exceptions import ObjectDoesNotExist from djangosaml2.backends import Saml2Backend from .. import settings as app_settings @@ -12,23 +11,27 @@ def _update_user(self, user, attributes, attribute_mapping, force_save=False): ): # Skip updating user's username if the user didn't signed up # with SAML registration method. - try: - attribute_mapping = attribute_mapping.copy() - # Check if any of the user's registered_users records - # were NOT created via SAML - has_non_saml = user.registered_users.exclude(method="saml").exists() - if has_non_saml: - for key, value in attribute_mapping.items(): - if "username" in value: - break - if len(value) == 1: - attribute_mapping.pop(key, None) - else: - attribute_mapping[key] = [] - for attr in value: - if attr != "username": - attribute_mapping[key].append(attr) - - except ObjectDoesNotExist: - pass + attribute_mapping = attribute_mapping.copy() + # Check if any of the user's registered_users records + # were NOT created via SAML. + # NOTE: This uses a global check (any org) rather than org-specific. + # This is intentionally conservative: if a user has ever signed up + # via a non-SAML method in any org, their username won't be updated + # during SAML login in any org. This prevents the SAML identity + # provider from overwriting a username set or preferred by the user + # elsewhere. Since the User model is shared across organizations, + # updating the username based solely on one org's SAML flow could + # unexpectedly change the user's identity in other orgs. + has_non_saml = user.registered_users.exclude(method="saml").exists() + if has_non_saml: + for key, value in attribute_mapping.items(): + if "username" in value: + break + if len(value) == 1: + attribute_mapping.pop(key, None) + else: + attribute_mapping[key] = [] + for attr in value: + if attr != "username": + attribute_mapping[key].append(attr) return super()._update_user(user, attributes, attribute_mapping, force_save) diff --git a/openwisp_radius/social/views.py b/openwisp_radius/social/views.py index c94f6b63..21db4fe2 100644 --- a/openwisp_radius/social/views.py +++ b/openwisp_radius/social/views.py @@ -53,7 +53,7 @@ def authorize(self, request, org, *args, **kwargs): organization=org, defaults={"method": "social_login", "is_verified": False}, ) - if created: + if not created: registered_user.full_clean() registered_user.save() diff --git a/openwisp_radius/tests/test_api/test_freeradius_api.py b/openwisp_radius/tests/test_api/test_freeradius_api.py index 8dca8c95..d505141a 100644 --- a/openwisp_radius/tests/test_api/test_freeradius_api.py +++ b/openwisp_radius/tests/test_api/test_freeradius_api.py @@ -288,6 +288,48 @@ def test_global_fallback_for_orgs_without_specific_records(self): ) self.assertEqual(response.data["control:Auth-Type"], "Accept") + def test_global_verified_with_org_unverified(self): + """ + A user with a global verified RegisteredUser should NOT be + authorized for an org where they have an org-specific unverified RegisteredUser. + The org-specific record takes precedence over the global fallback. + """ + org = self._get_org() + org_settings = OrganizationRadiusSettings.objects.get(organization=org) + org_settings.needs_identity_verification = True + org_settings.save() + user = self._get_user_with_org() + RegisteredUser.objects.create(user=user, organization=org, is_verified=False) + RegisteredUser.objects.create(user=user, organization=None, is_verified=True) + auth_header = f"Bearer {org.pk} {org.radius_settings.token}" + response = self._authorize_user(username=user.username, auth_header=auth_header) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, None) + + @mock.patch.object(registration, "AUTHORIZE_UNVERIFIED", ["mobile_phone"]) + def test_global_special_method_with_org_unverified_not_authorized(self): + """ + When AUTHORIZE_UNVERIFIED is set, the org-specific + record still takes precedence. A user with org-specific unverified record + using a non-special method should NOT be authorized even if they have a + global record with a special method. + """ + org = self._get_org() + org_settings = OrganizationRadiusSettings.objects.get(organization=org) + org_settings.needs_identity_verification = True + org_settings.save() + user = self._get_user_with_org() + RegisteredUser.objects.create( + user=user, organization=org, method="email", is_verified=False + ) + RegisteredUser.objects.create( + user=user, organization=None, method="mobile_phone", is_verified=True + ) + auth_header = f"Bearer {org.pk} {org.radius_settings.token}" + response = self._authorize_user(username=user.username, auth_header=auth_header) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, None) + def test_authorize_radius_token_unverified_user(self): user = self._get_org_user() org_settings = OrganizationRadiusSettings.objects.get( @@ -2242,7 +2284,7 @@ def test_automatic_groupname_account_enabled(self): ) user.radiususergroup_set.set([usergroup1, usergroup2]) self.client.post( - f'{reverse("radius:accounting")}{self.token_querystring}', + f"{reverse('radius:accounting')}{self.token_querystring}", { "status_type": "Start", "session_time": "", @@ -2281,7 +2323,7 @@ def test_multiple_radius_group_with_different_org_and_priority(self): ) user.radiususergroup_set.set([usergroup1, usergroup2]) self.client.post( - f'{reverse("radius:accounting")}{self.token_querystring}', + f"{reverse('radius:accounting')}{self.token_querystring}", { "status_type": "Start", "session_time": "", @@ -2301,7 +2343,7 @@ def test_multiple_radius_group_with_different_org_and_priority(self): def test_mac_authentication_with_no_logging(self, logger): username = "5c:7d:c1:72:a7:3b" self.client.post( - f'{reverse("radius:accounting")}{self.token_querystring}', + f"{reverse('radius:accounting')}{self.token_querystring}", { "status_type": "Start", "session_time": "", @@ -2336,7 +2378,7 @@ def test_account_creation_api_automatic_groupname_disabled(self): groupname="group2", priority=1, username="testgroup2" ) user.radiususergroup_set.set([usergroup1, usergroup2]) - url = f'{reverse("radius:accounting")}{self.token_querystring}' + url = f"{reverse('radius:accounting')}{self.token_querystring}" self.client.post( url, { @@ -2401,12 +2443,15 @@ def test_ip_from_setting_invalid(self): "Request rejected: (localhost) in organization settings or " "settings.py is not a valid IP address. Please contact administrator." ) - with mock.patch( - "openwisp_radius.settings.FREERADIUS_ALLOWED_HOSTS", ["localhost"] - ), mock.patch.object( - OrganizationRadiusSettings._meta.get_field("freeradius_allowed_hosts"), - "from_db_value", - return_value="localhost", + with ( + mock.patch( + "openwisp_radius.settings.FREERADIUS_ALLOWED_HOSTS", ["localhost"] + ), + mock.patch.object( + OrganizationRadiusSettings._meta.get_field("freeradius_allowed_hosts"), + "from_db_value", + return_value="localhost", + ), ): response = self.client.post(reverse("radius:authorize"), self.params) self.assertEqual(response.status_code, 403) @@ -2524,7 +2569,7 @@ def test_cache(self): ) self._get_org_user() token_querystring = f"?token={rad.token}&uuid={str(self.org.pk)}" - post_url = f'{reverse("radius:authorize")}{token_querystring}' + post_url = f"{reverse('radius:authorize')}{token_querystring}" # Clear cache before sending request cache.clear() self.client.post(post_url, {"username": "tester", "password": "tester"}) @@ -2547,7 +2592,7 @@ def test_cache(self): def test_no_org_radius_setting(self): self._get_org_user() token_querystring = f"?token=12345&uuid={str(self.org.pk)}" - post_url = f'{reverse("radius:authorize")}{token_querystring}' + post_url = f"{reverse('radius:authorize')}{token_querystring}" r = self.client.post(post_url, {"username": "tester", "password": "tester"}) self.assertEqual(r.status_code, 403) self.assertEqual(r.data, {"detail": "Token authentication failed"}) @@ -2559,7 +2604,7 @@ def test_uuid_in_cache(self): cache.set("uuid", str(self.org.pk), 30) self._get_org_user() token_querystring = f"?token={rad.token}&uuid={str(self.org.pk)}" - post_url = f'{reverse("radius:authorize")}{token_querystring}' + post_url = f"{reverse('radius:authorize')}{token_querystring}" r = self.client.post(post_url, {"username": "tester", "password": "tester"}) self.assertEqual(r.status_code, 200) diff --git a/openwisp_radius/tests/test_users_integration.py b/openwisp_radius/tests/test_users_integration.py index 74731c39..5281b46f 100644 --- a/openwisp_radius/tests/test_users_integration.py +++ b/openwisp_radius/tests/test_users_integration.py @@ -104,7 +104,7 @@ def test_export_users_command(self): method="mobile_phone", is_verified=False, ) - with self.assertNumQueries(2): + with self.assertNumQueries(3): call_command("export_users", filename=temp_file.name) with open(temp_file.name, "r") as file: From adf2fdaeed2ae6267a78b983b33e7ea187286b16 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 29 Apr 2026 20:26:50 +0530 Subject: [PATCH 20/27] [tests] Improved tests for migration --- openwisp_radius/admin.py | 2 +- openwisp_radius/tests/test_migrations.py | 268 ++++++++++++++++++++++- 2 files changed, 259 insertions(+), 11 deletions(-) diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 78f7dc67..c500c97f 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -539,7 +539,7 @@ class RegisteredUserInline(StackedInline): form = AlwaysHasChangedForm extra = 0 readonly_fields = ("modified",) - fields = ("method", "is_verified", "modified") + fields = ("organization", "method", "is_verified", "modified") def has_delete_permission(self, request, obj=None): return False diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py index 1157bf1a..ce532fa1 100644 --- a/openwisp_radius/tests/test_migrations.py +++ b/openwisp_radius/tests/test_migrations.py @@ -34,7 +34,6 @@ def test_multitenant_reverse_updates_weaker_existing_global(self): modified=modified_base ) existing_global.refresh_from_db() - # Create org-scoped email (same strength as global but newer) org_email = RegisteredUser.objects.create( user=user, @@ -54,8 +53,9 @@ def test_multitenant_reverse_updates_weaker_existing_global(self): is_verified=True, method="mobile_phone", ) + expected_modified = modified_base - timezone.timedelta(minutes=10) RegisteredUser.objects.filter(pk=org_mobile.pk).update( - modified=modified_base - timezone.timedelta(minutes=10) + modified=expected_modified ) org_mobile.refresh_from_db() @@ -65,9 +65,9 @@ def test_multitenant_reverse_updates_weaker_existing_global(self): ) existing_global.refresh_from_db() - self.assertIsNone(existing_global.organization) + self.assertEqual(existing_global.organization, None) self.assertEqual(existing_global.method, "mobile_phone") - self.assertTrue(existing_global.is_verified) + self.assertEqual(existing_global.is_verified, True) self.assertEqual(existing_global.modified, org_mobile.modified) self.assertEqual( RegisteredUser.objects.filter( @@ -85,7 +85,6 @@ def test_multitenant_reverse_keeps_stronger_existing_global(self): user = self._create_user(username="rollback-global-wins") org = self._create_org(name="rollback-org-3", slug="rollback-org-3") modified_base = timezone.now() - # Create a stronger existing global (method="mobile_phone", newer timestamp) existing_global = RegisteredUser.objects.create( user=user, @@ -97,7 +96,6 @@ def test_multitenant_reverse_keeps_stronger_existing_global(self): modified=modified_base + timezone.timedelta(minutes=10) ) existing_global.refresh_from_db() - # Create weaker org-scoped (method="social_login", older timestamp) org_specific = RegisteredUser.objects.create( user=user, @@ -107,12 +105,10 @@ def test_multitenant_reverse_keeps_stronger_existing_global(self): ) RegisteredUser.objects.filter(pk=org_specific.pk).update(modified=modified_base) org_specific.refresh_from_db() - # Rollback: global should remain unchanged (stronger), org-scoped deleted migrate_registered_users_multitenant_reverse( apps, None, app_label="openwisp_radius" ) - existing_global.refresh_from_db() self.assertIsNone(existing_global.organization) self.assertEqual(existing_global.method, "mobile_phone") @@ -121,9 +117,261 @@ def test_multitenant_reverse_keeps_stronger_existing_global(self): existing_global.modified, modified_base + timezone.timedelta(minutes=10), ) + self.assertFalse( + RegisteredUser.objects.filter( + user=user, organization__isnull=False + ).exists() + ) + + def test_multitenant_reverse_creates_global_when_missing(self): + """ + Test that if no global record exists, a new global record is created + from the strongest org-scoped record. + """ + user = self._create_user(username="no-global-user") + org1 = self._create_org(name="no-global-org-1", slug="no-global-org-1") + org2 = self._create_org(name="no-global-org-2", slug="no-global-org-2") + modified_base = timezone.now() + # Verify no global exists + self.assertFalse( + RegisteredUser.objects.filter(user=user, organization__isnull=True).exists() + ) + # Create weaker org-scoped (email, unverified) + org_email = RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=False, + method="email", + ) + RegisteredUser.objects.filter(pk=org_email.pk).update(modified=modified_base) + # Create stronger org-scoped (mobile_phone, verified) + org_mobile = RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=True, + method="mobile_phone", + ) + expected_modified = modified_base - timezone.timedelta(minutes=10) + RegisteredUser.objects.filter(pk=org_mobile.pk).update( + modified=expected_modified + ) + org_mobile.refresh_from_db() + # Rollback: should create global from strongest org record + migrate_registered_users_multitenant_reverse( + apps, None, app_label="openwisp_radius" + ) + # Verify global created with strongest record's data + global_record = RegisteredUser.objects.get(user=user, organization__isnull=True) + self.assertEqual(global_record.is_verified, True) + self.assertEqual(global_record.method, "mobile_phone") + self.assertEqual(global_record.modified, expected_modified) + # Verify all org-scoped records deleted + self.assertFalse( + RegisteredUser.objects.filter( + user=user, organization__isnull=False + ).exists() + ) + + def test_multitenant_reverse_verified_wins_over_method(self): + """ + Test that is_verified=True always wins over False, regardless of method + strength. + """ + user = self._create_user(username="verified-wins-user") + org1 = self._create_org(name="verified-org-1", slug="verified-org-1") + org2 = self._create_org(name="verified-org-2", slug="verified-org-2") + modified_base = timezone.now() + # Strong method but unverified + org_strong_method = RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=False, + method="mobile_phone", + ) + RegisteredUser.objects.filter(pk=org_strong_method.pk).update( + modified=modified_base + ) + # Weaker method but verified + org_weak_method = RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=True, + method="email", + ) + RegisteredUser.objects.filter(pk=org_weak_method.pk).update( + modified=modified_base - timezone.timedelta(minutes=10) + ) + org_weak_method.refresh_from_db() + # Rollback: verified should win despite weaker method + migrate_registered_users_multitenant_reverse( + apps, None, app_label="openwisp_radius" + ) + global_record = RegisteredUser.objects.get(user=user, organization__isnull=True) + self.assertEqual(global_record.is_verified, True) + self.assertEqual(global_record.method, "email") + + def test_multitenant_reverse_multiple_org_competition(self): + """ + Test correct ordering when multiple org-scoped records compete. + """ + user = self._create_user(username="multi-org-user") + org1 = self._create_org(name="multi-org-1", slug="multi-org-1") + org2 = self._create_org(name="multi-org-2", slug="multi-org-2") + org3 = self._create_org(name="multi-org-3", slug="multi-org-3") + modified_base = timezone.now() + # Org1: unverified, empty method, oldest + org1_record = RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=False, + method="", + ) + RegisteredUser.objects.filter(pk=org1_record.pk).update( + modified=modified_base - timezone.timedelta(minutes=30) + ) + # Org2: verified, email method, middle timestamp + org2_record = RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=True, + method="email", + ) + RegisteredUser.objects.filter(pk=org2_record.pk).update( + modified=modified_base - timezone.timedelta(minutes=15) + ) + org2_record.refresh_from_db() + # Org3: verified, mobile_phone method, newest (should win) + org3_record = RegisteredUser.objects.create( + user=user, + organization=org3, + is_verified=True, + method="mobile_phone", + ) + expected_modified = modified_base + RegisteredUser.objects.filter(pk=org3_record.pk).update( + modified=expected_modified + ) + org3_record.refresh_from_db() + # Rollback: org3 should win (verified + strongest method) + migrate_registered_users_multitenant_reverse( + apps, None, app_label="openwisp_radius" + ) + global_record = RegisteredUser.objects.get(user=user, organization__isnull=True) + self.assertTrue(global_record.is_verified) + self.assertEqual(global_record.method, "mobile_phone") + self.assertEqual(global_record.modified, expected_modified) + # Only one record should exist + self.assertEqual(RegisteredUser.objects.filter(user=user).count(), 1) + + def test_multitenant_reverse_equal_strength_keeps_global(self): + """ + Test that when org-scoped record has equal strength to existing global, + the global is NOT updated (comparison uses > not >=). + """ + user = self._create_user(username="equal-strength-user") + org = self._create_org(name="equal-org", slug="equal-org") + modified_base = timezone.now() + # Create existing global + existing_global = RegisteredUser.objects.create( + user=user, + organization=None, + is_verified=True, + method="email", + ) + RegisteredUser.objects.filter(pk=existing_global.pk).update( + modified=modified_base + ) + existing_global.refresh_from_db() + # Create org-scoped with IDENTICAL strength + org_record = RegisteredUser.objects.create( + user=user, + organization=org, + is_verified=True, + method="email", + ) + RegisteredUser.objects.filter(pk=org_record.pk).update(modified=modified_base) + # Rollback: global should remain unchanged (equal strength, not greater) + migrate_registered_users_multitenant_reverse( + apps, None, app_label="openwisp_radius" + ) + existing_global.refresh_from_db() + self.assertEqual(existing_global.organization, None) + self.assertEqual(existing_global.method, "email") + self.assertEqual(existing_global.modified, modified_base) + self.assertEqual(existing_global.is_verified, True) + # Org-scoped should be deleted self.assertEqual( RegisteredUser.objects.filter( user=user, organization__isnull=False - ).count(), - 0, + ).exists(), + False, + ) + + def test_multitenant_reverse_method_priority_ordering(self): + """ + Test explicit method priority ordering: mobile_phone > email > empty. + """ + user = self._create_user(username="method-priority-user") + org1 = self._create_org(name="method-org-1", slug="method-org-1") + org2 = self._create_org(name="method-org-2", slug="method-org-2") + org3 = self._create_org(name="method-org-3", slug="method-org-3") + modified_base = timezone.now() + # All unverified, same timestamp - method should decide + org_empty = RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=False, + method="", + ) + RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=False, + method="email", + ) + RegisteredUser.objects.create( + user=user, + organization=org3, + is_verified=False, + method="mobile_phone", + ) + RegisteredUser.objects.update(modified=modified_base) + # Rollback: mobile_phone should win (highest method priority) + migrate_registered_users_multitenant_reverse( + apps, None, app_label="openwisp_radius" + ) + global_record = RegisteredUser.objects.get(user=user, organization__isnull=True) + self.assertEqual(global_record.method, "mobile_phone") + + def test_multitenant_reverse_full_cleanup(self): + """ + Test that no org-scoped records remain after migration. + """ + user1 = self._create_user( + username="cleanup-user-1", email="cleanup1@example.com" + ) + user2 = self._create_user( + username="cleanup-user-2", email="cleanup2@example.com" + ) + org1 = self._create_org(name="cleanup-org-1", slug="cleanup-org-1") + org2 = self._create_org(name="cleanup-org-2", slug="cleanup-org-2") + # Create multiple org-scoped records for multiple users + for user, org in [(user1, org1), (user1, org2), (user2, org1)]: + RegisteredUser.objects.create( + user=user, + organization=org, + is_verified=False, + method="email", + ) + # Verify org-scoped records exist + self.assertEqual( + RegisteredUser.objects.filter(organization__isnull=False).exists(), True + ) + # Rollback + migrate_registered_users_multitenant_reverse( + apps, None, app_label="openwisp_radius" + ) + # Verify NO org-scoped records remain + self.assertEqual( + RegisteredUser.objects.filter(organization__isnull=False).exists(), False ) From 4d77cf62c140a23db30e3fab38b18a78375d7c18 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 4 May 2026 17:44:17 +0530 Subject: [PATCH 21/27] [fix] Made requested changes --- openwisp_radius/admin.py | 18 +++++ openwisp_radius/base/models.py | 11 ++-- ...registered_user_multitenant_constraints.py | 6 +- openwisp_radius/tests/test_admin.py | 65 ++++++++++++++++--- 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index c500c97f..a6ca6ce0 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -7,6 +7,7 @@ from django.contrib.admin.utils import model_ngettext from django.contrib.auth import get_user_model from django.core.exceptions import PermissionDenied +from django.forms.models import BaseInlineFormSet from django.http import HttpResponseRedirect from django.templatetags.static import static from django.urls import reverse @@ -534,9 +535,26 @@ def has_change_permission(self, request, obj=None): return False +class RegisteredUserFormset(BaseInlineFormSet): + def get_unique_error_message(self, unique_check): + # Django inline formsets perform their own uniqueness validation + # (BaseModelFormSet.validate_unique) *before* model-level validation runs. + # Because of this, the custom `violation_error_message` defined on + # `UniqueConstraint` is never surfaced in the admin UI. + # + # Overriding this method allows us to replace Django’s generic + # "Please correct the duplicate data for ." message with a + # domain-specific, user-friendly error that matches our constraint. + if unique_check == ("user", "organization"): + return _( + "A user cannot have more than one registration record in the same organization." + ) + + class RegisteredUserInline(StackedInline): model = RegisteredUser form = AlwaysHasChangedForm + formset = RegisteredUserFormset extra = 0 readonly_fields = ("modified",) fields = ("organization", "method", "is_verified", "modified") diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index eeb1f0cb..e24f48d2 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -175,6 +175,9 @@ _LOGIN_URL_HELP_TEXT = _("Enter the URL where users can log in to the wifi service") _STATUS_URL_HELP_TEXT = _("Enter the URL where users can log out from the wifi service") _PASSWORD_RESET_URL_HELP_TEXT = _("Enter the URL where users can reset their password") +_REGISTRATION_UNIQUE_VALIDATION_ERROR = _( + "A user cannot have more than one registration record in the same organization." +) OPTIONAL_SETTINGS = app_settings.OPTIONAL_REGISTRATION_FIELDS @@ -1683,17 +1686,13 @@ class Meta: models.UniqueConstraint( fields=["user", "organization"], name="unique_registered_user_per_org", - violation_error_message=_( - "A registration record already exists for this user/organization." - ), + violation_error_message=_REGISTRATION_UNIQUE_VALIDATION_ERROR, ), models.UniqueConstraint( fields=["user"], condition=Q(organization__isnull=True), name="unique_global_registered_user", - violation_error_message=_( - "A registration record already exists for this user/organization." - ), + violation_error_message=_REGISTRATION_UNIQUE_VALIDATION_ERROR, ), ] diff --git a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py index 2f3af8f4..e86499fc 100644 --- a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py +++ b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py @@ -13,7 +13,8 @@ class Migration(migrations.Migration): fields=["user", "organization"], name="unique_registered_user_per_org", violation_error_message=( - "A registration record already exists for this user/organization." + "A user cannot have more than one registration record in the same" + " organization." ), ), ), @@ -24,7 +25,8 @@ class Migration(migrations.Migration): condition=models.Q(organization__isnull=True), name="unique_global_registered_user", violation_error_message=( - "A registration record already exists for this user/organization." + "A user cannot have more than one registration record in the same" + " organization." ), ), ), diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index 68b6de13..e7a38ac0 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -671,16 +671,19 @@ def test_backward_compatible_default_password_reset_url(self): f"admin:{self.app_label_users}_organization_add", ) PASSWORD_RESET_URLS = {"default": default_password_reset_url} - with mock.patch.object( - app_settings, - "DEFAULT_PASSWORD_RESET_URL", - app_settings.get_default_password_reset_url(PASSWORD_RESET_URLS), - ), mock.patch.object( - # The default value is set on project startup, hence - # it also requires mocking. - OrganizationRadiusSettings._meta.get_field("password_reset_url"), - "fallback", - app_settings.DEFAULT_PASSWORD_RESET_URL, + with ( + mock.patch.object( + app_settings, + "DEFAULT_PASSWORD_RESET_URL", + app_settings.get_default_password_reset_url(PASSWORD_RESET_URLS), + ), + mock.patch.object( + # The default value is set on project startup, hence + # it also requires mocking. + OrganizationRadiusSettings._meta.get_field("password_reset_url"), + "fallback", + app_settings.DEFAULT_PASSWORD_RESET_URL, + ), ): response = self.client.get(url) self.assertContains(response, default_password_reset_url) @@ -1407,6 +1410,48 @@ def test_inline_registered_user(self): register_registration_method("github", "GitHub", strong_identity=False) self.assertIn("github", RegisteredUser._weak_verification_methods) + def test_admin_prevents_duplicate_registered_user_same_org(self): + user = self._create_user(username="dup_test_user", email="dup@test.org") + reg_user = RegisteredUser.objects.create( + user=user, organization=self.default_org, is_verified=True + ) + user_change_url = reverse( + f"admin:{User._meta.app_label}_user_change", args=[user.pk] + ) + response = self.client.get(user_change_url) + self.assertEqual(response.status_code, 200) + data = { + "username": "dup_test_user", + "email": "dup@test.org", + "registered_users-TOTAL_FORMS": "2", + "registered_users-INITIAL_FORMS": "1", + "registered_users-MIN_NUM_FORMS": "0", + "registered_users-MAX_NUM_FORMS": "1000", + "registered_users-0-id": str(reg_user.pk), + "registered_users-0-user": str(user.pk), + "registered_users-0-organization": str(self.default_org.pk), + "registered_users-0-method": "", + "registered_users-0-is_verified": "on", + "registered_users-1-id": "", + "registered_users-1-user": str(user.pk), + "registered_users-1-organization": str(self.default_org.pk), + "registered_users-1-method": "", + "registered_users-1-is_verified": "on", + } + response = self.client.post(user_change_url, data) + self.assertContains(response, "errors") + self.assertContains( + response, + "A user cannot have more than one registration record in the" + " same organization.", + ) + self.assertEqual( + RegisteredUser.objects.filter( + user=user, organization=self.default_org + ).count(), + 1, + ) + def test_user_admin_shows_multiple_registered_user_records(self): user = self._create_user(username="multiuser", email="multi@test.org") org2 = self._create_org(name="org2", slug="org2") From 24ea05259252de30f3bec8e0e2d03b37bce0800f Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 4 May 2026 19:06:53 +0530 Subject: [PATCH 22/27] [fix] Fixes QA issues --- openwisp_radius/admin.py | 3 ++- openwisp_radius/tests/test_migrations.py | 2 +- .../migrations/0032_registered_user_multitenant.py | 8 ++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 914379c5..92775786 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -547,7 +547,8 @@ def get_unique_error_message(self, unique_check): # domain-specific, user-friendly error that matches our constraint. if unique_check == ("user", "organization"): return _( - "A user cannot have more than one registration record in the same organization." + "A user cannot have more than one registration record in the" + " same organization." ) diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py index ce532fa1..56572b1f 100644 --- a/openwisp_radius/tests/test_migrations.py +++ b/openwisp_radius/tests/test_migrations.py @@ -317,7 +317,7 @@ def test_multitenant_reverse_method_priority_ordering(self): org3 = self._create_org(name="method-org-3", slug="method-org-3") modified_base = timezone.now() # All unverified, same timestamp - method should decide - org_empty = RegisteredUser.objects.create( + RegisteredUser.objects.create( user=user, organization=org1, is_verified=False, diff --git a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py index a53e399f..b8f46e69 100644 --- a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py +++ b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py @@ -207,6 +207,10 @@ class Migration(migrations.Migration): constraint=models.UniqueConstraint( fields=["user", "organization"], name="unique_registered_user_per_org", + violation_error_message=( + "A user cannot have more than one registration" + " record in the same organization." + ), ), ), migrations.AddConstraint( @@ -215,6 +219,10 @@ class Migration(migrations.Migration): fields=["user"], condition=models.Q(organization__isnull=True), name="unique_global_registered_user", + violation_error_message=( + "A user cannot have more than one registration" + " record in the same organization." + ), ), ), ] From 29857b0934d2c11a2916b1d2a1292a807e3f8285 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 4 May 2026 21:44:24 +0530 Subject: [PATCH 23/27] [fix] Fixed test --- openwisp_radius/api/views.py | 2 +- openwisp_radius/tests/test_api/test_phone_verification.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 5216d8b3..9d86add9 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -780,7 +780,7 @@ def post(self, request, *args, **kwargs): # we can write it to the user field user.phone_number = phone_token.phone_number user.save() - reg_user.save(update_fields=["is_verified", "method"]) + reg_user.save() # delete any radius token cache key if present cache.delete(f"rt-{phone_token.phone_number}") return Response(None, status=200) diff --git a/openwisp_radius/tests/test_api/test_phone_verification.py b/openwisp_radius/tests/test_api/test_phone_verification.py index 5a13d880..7fd10451 100644 --- a/openwisp_radius/tests/test_api/test_phone_verification.py +++ b/openwisp_radius/tests/test_api/test_phone_verification.py @@ -338,8 +338,8 @@ def test_phone_token_status_400_not_member(self): self.assertIn("non_field_errors", r.data) self.assertIn("is not member", str(r.data["non_field_errors"])) - @freeze_time(_TEST_DATE) @capture_any_output() + @freeze_time(_TEST_DATE) def test_validate_phone_token_200(self): self.test_create_phone_token_201() user = User.objects.get(email=self._test_email) @@ -828,7 +828,7 @@ def _create_user_helper(self, options): r1 = self.client.post( url, content_type="application/json", - HTTP_AUTHORIZATION=f'Bearer {r.data["key"]}', + HTTP_AUTHORIZATION=f"Bearer {r.data['key']}", ) self.assertEqual(r1.status_code, 201) From 4558a7465eed1cbd5f9e7b2a0171af5fd326f8d7 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 5 May 2026 18:30:00 +0530 Subject: [PATCH 24/27] [fix] Fixed tests --- .../tests/test_users_integration.py | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/openwisp_radius/tests/test_users_integration.py b/openwisp_radius/tests/test_users_integration.py index 5281b46f..2a0010ce 100644 --- a/openwisp_radius/tests/test_users_integration.py +++ b/openwisp_radius/tests/test_users_integration.py @@ -98,12 +98,20 @@ def test_export_users_command(self): temp_file = NamedTemporaryFile(delete=False) org_user = self._create_org_user() user = org_user.user - reg_user = RegisteredUser.objects.create( + org2 = self._create_org(name="Test Organization 2") + self._create_org_user(organization=org2, user=user) + org1_reg_user = RegisteredUser.objects.create( user=user, organization=org_user.organization, method="mobile_phone", is_verified=False, ) + org2_reg_user = RegisteredUser.objects.create( + user=user, + organization=org2, + method="mobile_phone", + is_verified=True, + ) with self.assertNumQueries(3): call_command("export_users", filename=temp_file.name) @@ -112,10 +120,18 @@ def test_export_users_command(self): csv_data = list(csv_reader) self.assertEqual(len(csv_data), 2) - self.assertIn("registered_users", csv_data[0]) + self.assertIn( + "registered_users (organization_id, method, is_verified)", csv_data[0] + ) self.assertEqual( csv_data[1][-1], - f"(({reg_user.organization_id},{reg_user.method},{reg_user.is_verified}))", + ( + f"({org1_reg_user.organization_id},{org1_reg_user.method}," + f"{org1_reg_user.is_verified})" + "\n" + f"({org2_reg_user.organization_id},{org2_reg_user.method}," + f"{org2_reg_user.is_verified})" + ), ) def test_radiususergroup_inline(self): From caa81bb55e9413c6bf0901b0d12bf38491ae54f1 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 7 May 2026 16:37:10 +0530 Subject: [PATCH 25/27] [fix] Removed global RegisteredUser object --- openwisp_radius/api/serializers.py | 9 +- openwisp_radius/api/utils.py | 4 - openwisp_radius/base/models.py | 26 +- .../integrations/monitoring/tasks.py | 6 - .../monitoring/tests/test_metrics.py | 10 +- .../0043_registereduser_add_uuid.py | 9 +- ...registered_user_multitenant_constraints.py | 12 - openwisp_radius/migrations/__init__.py | 76 +-- openwisp_radius/tests/test_admin.py | 3 +- openwisp_radius/tests/test_api/test_api.py | 18 +- .../tests/test_api/test_freeradius_api.py | 34 +- openwisp_radius/tests/test_migrations.py | 436 +++++++----------- openwisp_radius/tests/test_models.py | 61 +-- .../0032_registered_user_multitenant.py | 21 +- 14 files changed, 258 insertions(+), 467 deletions(-) diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 5fba1c00..8b878f4a 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -839,8 +839,7 @@ def _get_registered_user(self, obj): if obj.pk not in self._registered_user_cache: view = self.context.get("view") organization = getattr(view, "organization", None) - org_reg_user = None - global_reg_user = None + reg_user = None # We iterate over .all() instead of using .filter() because callers # of this serializer (e.g. validate_auth_token) prefetch # "registered_users" via prefetch_related. Using .all() hits the @@ -848,11 +847,9 @@ def _get_registered_user(self, obj): # bypass the cache and issue a new query every time. for ru in obj.registered_users.all(): if organization and ru.organization_id == organization.pk: - org_reg_user = ru + reg_user = ru break - elif ru.organization_id is None: - global_reg_user = ru - self._registered_user_cache[obj.pk] = org_reg_user or global_reg_user + self._registered_user_cache[obj.pk] = reg_user return self._registered_user_cache[obj.pk] def get_is_verified(self, obj): diff --git a/openwisp_radius/api/utils.py b/openwisp_radius/api/utils.py index aaa4f9f6..447ca7c5 100644 --- a/openwisp_radius/api/utils.py +++ b/openwisp_radius/api/utils.py @@ -33,16 +33,12 @@ def _needs_identity_verification(self, organization_filter_kwargs={}, org=None): def is_identity_verified_strong(self, user, organization=None): reg_user = None - global_reg_user = None # We use all() to utilize the prefetch cache, otherwise # it would cause an additional query to fetch the registered user for ru in user.registered_users.all(): if organization and ru.organization_id == organization.pk: reg_user = ru break - elif ru.organization_id is None: - global_reg_user = ru - reg_user = reg_user or global_reg_user if reg_user is None: return False return reg_user.is_identity_verified_strong diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index e24f48d2..300fcc87 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1587,9 +1587,7 @@ def is_valid(self, token, organization=None): def _validate_already_verified(self, organization=None): RegisteredUser = swapper.load_model("openwisp_radius", "RegisteredUser") if organization is not None: - reg_user = RegisteredUser.get_global_or_org_specific( - self.user, organization - ) + reg_user = RegisteredUser.get_for_user_and_org(self.user, organization) is_verified = reg_user is not None and reg_user.is_verified else: is_verified = RegisteredUser.objects.filter( @@ -1634,13 +1632,8 @@ class AbstractRegisteredUser(UUIDModel): swapper.get_model_name("openwisp_users", "Organization"), on_delete=models.CASCADE, related_name="registered_users", - null=True, - blank=True, verbose_name=_("organization"), - help_text=( - "The organization this registration info belongs to. " - "If null, applies to all orgs without specific requirements." - ), + help_text=_("Organization associated with this registered user entry."), ) method = models.CharField( _("registration method"), @@ -1688,12 +1681,6 @@ class Meta: name="unique_registered_user_per_org", violation_error_message=_REGISTRATION_UNIQUE_VALIDATION_ERROR, ), - models.UniqueConstraint( - fields=["user"], - condition=Q(organization__isnull=True), - name="unique_global_registered_user", - violation_error_message=_REGISTRATION_UNIQUE_VALIDATION_ERROR, - ), ] @classmethod @@ -1704,14 +1691,9 @@ def get_or_create_for_user_and_org(cls, user, organization, defaults=None): ) @classmethod - def get_global_or_org_specific(cls, user, organization=None): - if organization: - try: - return cls.objects.get(user=user, organization=organization) - except cls.DoesNotExist: - pass + def get_for_user_and_org(cls, user, organization): try: - return cls.objects.get(user=user, organization__isnull=True) + return cls.objects.get(user=user, organization=organization) except cls.DoesNotExist: return None diff --git a/openwisp_radius/integrations/monitoring/tasks.py b/openwisp_radius/integrations/monitoring/tasks.py index 7a2375cf..e46affd3 100644 --- a/openwisp_radius/integrations/monitoring/tasks.py +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -190,12 +190,6 @@ def post_save_radiusaccounting( .filter(user__username=username, organization_id=organization_id) .first() ) - if registration_method is None: - registration_method = ( - RegisteredUser.objects.only("method") - .filter(user__username=username, organization__isnull=True) - .first() - ) if registration_method is None: logger.info( f'RegisteredUser object not found for "{username}".' diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index fe19a852..2cc598ee 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -432,18 +432,20 @@ def test_post_save_radiusaccounting_pending_verification(self): 1, ) - def test_post_save_radiusaccounting_org_specific_takes_precedence_over_global( + def test_post_save_radiusaccounting_does_not_fallback_to_other_org( self, ): """ - Test that when a user has both a global (organization=None) and org-specific - RegisteredUser, the org-specific one takes precedence. + Test that a RegisteredUser from another organization is not used + when accounting is written for the current organization. """ user = self._create_user() - self._create_registered_user(user=user, organization=None, method="email") self._create_registered_user( user=user, organization=self.default_org, method="mobile_phone" ) + org2 = self._create_org(name="metrics-org-2", slug="metrics-org-2") + self._create_org_user(user=user, organization=org2) + self._create_registered_user(user=user, organization=org2, method="email") device = self._create_device() device_loc = self._create_device_location( content_object=device, diff --git a/openwisp_radius/migrations/0043_registereduser_add_uuid.py b/openwisp_radius/migrations/0043_registereduser_add_uuid.py index a516c846..3cff024b 100644 --- a/openwisp_radius/migrations/0043_registereduser_add_uuid.py +++ b/openwisp_radius/migrations/0043_registereduser_add_uuid.py @@ -53,9 +53,7 @@ class Migration(migrations.Migration): field=models.ForeignKey( blank=True, help_text=( - "The organization this registration info belongs to. " - "If null, applies to all orgs without specific" - " requirements." + "Organization associated with this registered user entry." ), null=True, related_name="registered_users", @@ -125,9 +123,8 @@ class Migration(migrations.Migration): models.ForeignKey( blank=True, help_text=( - "The organization this registration info belongs" - " to. If null, applies to all orgs without" - " specific requirements." + "Organization associated with this registered user" + " entry." ), null=True, on_delete=django.db.models.deletion.CASCADE, diff --git a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py index e86499fc..7c87b5c8 100644 --- a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py +++ b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py @@ -18,16 +18,4 @@ class Migration(migrations.Migration): ), ), ), - migrations.AddConstraint( - model_name="registereduser", - constraint=models.UniqueConstraint( - fields=["user"], - condition=models.Q(organization__isnull=True), - name="unique_global_registered_user", - violation_error_message=( - "A user cannot have more than one registration record in the same" - " organization." - ), - ), - ), ] diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index d907949b..e770fd78 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -146,8 +146,6 @@ def migrate_registered_users_multitenant_forward( apps, schema_editor, app_label, extra_fields=() ): RegisteredUser = apps.get_model(app_label, "RegisteredUser") - if RegisteredUser._meta.swapped: - return OrganizationUser = get_swapped_model(apps, "openwisp_users", "OrganizationUser") queryset = RegisteredUser.objects.filter(organization__isnull=True).order_by( @@ -198,77 +196,45 @@ def migrate_registered_users_multitenant_forward( def migrate_registered_users_multitenant_reverse( apps, schema_editor, app_label, extra_fields=() ): + # Keep the strongest RegisteredUser per user and delete the weaker duplicates. + # Ranking is by: verified over unverified, stronger method over weaker method, + # then newer modified timestamps over older ones. RegisteredUser = apps.get_model(app_label, "RegisteredUser") - if RegisteredUser._meta.swapped: - return - + # Process users in batches so the migration scales to large tables without + # issuing one query per user. user_ids_qs = ( - RegisteredUser.objects.filter(organization__isnull=False) - .order_by() - .values_list("user_id", flat=True) - .distinct() + RegisteredUser.objects.order_by().values_list("user_id", flat=True).distinct() ) for user_id_batch in _batched_iterator( user_ids_qs.iterator(chunk_size=BATCH_SIZE), BATCH_SIZE ): - existing_globals = { - registered_user.user_id: registered_user - for registered_user in RegisteredUser.objects.filter( - user_id__in=user_id_batch, - organization__isnull=True, - ) - } # Annotate each row with an explicit verification priority so that stronger # methods (anything that is not '' or 'email') sort before weaker ones. - # Lexical ordering of 'method' would place '' first, picking the weakest. method_priority = _registered_user_method_priority_case() - org_records = ( + ranked_registered_users = ( RegisteredUser.objects.filter( user_id__in=user_id_batch, - organization__isnull=False, ) .annotate(method_priority=method_priority) .order_by("user_id", "-is_verified", "-method_priority", "-modified") ) - - to_create = [] - to_update = [] to_delete_pks = [] current_user_id = None - update_fields = ["is_verified", "method", "modified", *extra_fields] - - for registered_user in org_records.iterator(chunk_size=BATCH_SIZE): - if registered_user.user_id == current_user_id: + for registered_user in ranked_registered_users.iterator(chunk_size=BATCH_SIZE): + # Rows for the same user are consecutive because of the ordering + # above, and the first row in each group is the strongest one. + # Every later row for that user is therefore a weaker duplicate. + is_duplicate_for_user = registered_user.user_id == current_user_id + if is_duplicate_for_user: to_delete_pks.append(registered_user.pk) - continue - current_user_id = registered_user.user_id - existing_global = existing_globals.get(registered_user.user_id) - if existing_global is None: - restored = RegisteredUser( - id=uuid.uuid4(), - user_id=registered_user.user_id, - organization=None, - is_verified=registered_user.is_verified, - method=registered_user.method, - **_registered_user_extra_kwargs(registered_user, extra_fields), - ) - restored.modified = registered_user.modified - to_create.append(restored) - elif _registered_user_strength(registered_user) > _registered_user_strength( - existing_global - ): - existing_global.is_verified = registered_user.is_verified - existing_global.method = registered_user.method - existing_global.modified = registered_user.modified - for field_name, value in _registered_user_extra_kwargs( - registered_user, extra_fields - ).items(): - setattr(existing_global, field_name, value) - to_update.append(existing_global) - to_delete_pks.append(registered_user.pk) - - _flush_bulk_create(RegisteredUser, to_create) - _flush_bulk_update(RegisteredUser, to_update, fields=update_fields) + else: + current_user_id = registered_user.user_id + if len(to_delete_pks) >= BATCH_SIZE: + RegisteredUser.objects.filter(pk__in=to_delete_pks).delete() + to_delete_pks.clear() + + # Delete all weaker rows for the batch at once rather than issuing a + # separate delete for each user. if to_delete_pks: RegisteredUser.objects.filter(pk__in=to_delete_pks).delete() diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index e7a38ac0..4e430f8e 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -1459,14 +1459,13 @@ def test_user_admin_shows_multiple_registered_user_records(self): user=user, organization=self.default_org, is_verified=True ) RegisteredUser.objects.create(user=user, organization=org2, is_verified=False) - RegisteredUser.objects.create(user=user, organization=None, is_verified=True) user_url = reverse(f"admin:{User._meta.app_label}_user_change", args=[user.pk]) response = self.client.get(user_url) self.assertEqual(response.status_code, 200) self.assertContains( response, ( - '' ), ) diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 81c08312..2b1d8b72 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -406,14 +406,9 @@ def test_radius_user_serializer(self): }, ) - with self.subTest("org-specific takes precedence over global"): - # Create user with both a global (unverified) and - # org-specific (verified) record + with self.subTest("org-specific record is returned for the current org"): user2 = self._create_user(username="user2", email="user2@test.com") self._create_org_user(user=user2, organization=self.default_org) - RegisteredUser.objects.create( - user=user2, organization=None, is_verified=False - ) RegisteredUser.objects.create( user=user2, organization=self.default_org, @@ -426,18 +421,19 @@ def test_radius_user_serializer(self): self.assertEqual(r.data["is_verified"], True) self.assertEqual(r.data["method"], "mobile_phone") - with self.subTest("global record as fallback when no org-specific"): - # Create user with only a global (verified) record + with self.subTest("other-organization record is not used as fallback"): user3 = self._create_user(username="user3", email="user3@test.com") self._create_org_user(user=user3, organization=self.default_org) + org2 = self._create_org(name="serializer-org2", slug="serializer-org2") + self._create_org_user(user=user3, organization=org2) RegisteredUser.objects.create( - user=user3, organization=None, is_verified=True, method="email" + user=user3, organization=org2, is_verified=True, method="email" ) url = reverse("radius:user_auth_token", args=[self.default_org.slug]) r = self.client.post(url, {"username": "user3", "password": "tester"}) self.assertEqual(r.status_code, 200) - self.assertEqual(r.data["is_verified"], True) - self.assertEqual(r.data["method"], "email") + self.assertIsNone(r.data["is_verified"]) + self.assertIsNone(r.data["method"]) with self.subTest("returns None when no RegisteredUser records exist"): user4 = self._create_user(username="user4", email="user4@test.com") diff --git a/openwisp_radius/tests/test_api/test_freeradius_api.py b/openwisp_radius/tests/test_api/test_freeradius_api.py index d505141a..a847e586 100644 --- a/openwisp_radius/tests/test_api/test_freeradius_api.py +++ b/openwisp_radius/tests/test_api/test_freeradius_api.py @@ -223,14 +223,16 @@ def test_authorize_verified_user(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data, {"control:Auth-Type": "Accept"}) - with self.subTest("global verified record passes authorization (fallback)"): + with self.subTest("other-organization record does not pass authorization"): RegisteredUser.objects.filter(user=user).delete() + org2 = self._create_org(name="verified-org-2", slug="verified-org-2") + self._create_org_user(organization=org2, user=user) RegisteredUser.objects.create( - user=user, organization=None, is_verified=True + user=user, organization=org2, is_verified=True ) response = self._authorize_user(auth_header=self.auth_header) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data, {"control:Auth-Type": "Accept"}) + self.assertEqual(response.data, None) def test_multi_org_user_different_verification_states(self): org1 = self._get_org() @@ -259,7 +261,7 @@ def test_multi_org_user_different_verification_states(self): ) self.assertIsNone(response.data) - def test_global_fallback_for_orgs_without_specific_records(self): + def test_other_org_record_is_not_used_as_fallback(self): org1 = self._get_org() org2 = self._create_org(name="org2", slug="org2") org2_settings = OrganizationRadiusSettings.objects.get_or_create( @@ -270,17 +272,16 @@ def test_global_fallback_for_orgs_without_specific_records(self): org2_settings.save() user = self._get_user_with_org() self._create_org_user(organization=org2, user=user) - RegisteredUser.objects.create(user=user, organization=None, is_verified=True) + RegisteredUser.objects.create(user=user, organization=org2, is_verified=True) org_settings = OrganizationRadiusSettings.objects.get(organization=org1) org_settings.needs_identity_verification = True org_settings.save() - user.registered_users.exclude(organization=None).delete() auth_header_org1 = f"Bearer {org1.pk} {org1.radius_settings.token}" response = self._authorize_user( username=user.username, auth_header=auth_header_org1 ) - self.assertEqual(response.data["control:Auth-Type"], "Accept") + self.assertEqual(response.data, None) auth_header_org2 = f"Bearer {org2.pk} {org2.radius_settings.token}" response = self._authorize_user( @@ -288,42 +289,45 @@ def test_global_fallback_for_orgs_without_specific_records(self): ) self.assertEqual(response.data["control:Auth-Type"], "Accept") - def test_global_verified_with_org_unverified(self): + def test_other_org_verified_with_org_unverified(self): """ - A user with a global verified RegisteredUser should NOT be - authorized for an org where they have an org-specific unverified RegisteredUser. - The org-specific record takes precedence over the global fallback. + A user with a verified record in another org should not be + authorized for an org where they have an org-specific unverified record. """ org = self._get_org() org_settings = OrganizationRadiusSettings.objects.get(organization=org) org_settings.needs_identity_verification = True org_settings.save() user = self._get_user_with_org() + org2 = self._create_org(name="org2-priority", slug="org2-priority") + self._create_org_user(organization=org2, user=user) RegisteredUser.objects.create(user=user, organization=org, is_verified=False) - RegisteredUser.objects.create(user=user, organization=None, is_verified=True) + RegisteredUser.objects.create(user=user, organization=org2, is_verified=True) auth_header = f"Bearer {org.pk} {org.radius_settings.token}" response = self._authorize_user(username=user.username, auth_header=auth_header) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, None) @mock.patch.object(registration, "AUTHORIZE_UNVERIFIED", ["mobile_phone"]) - def test_global_special_method_with_org_unverified_not_authorized(self): + def test_other_org_special_method_with_org_unverified_not_authorized(self): """ When AUTHORIZE_UNVERIFIED is set, the org-specific record still takes precedence. A user with org-specific unverified record using a non-special method should NOT be authorized even if they have a - global record with a special method. + verified record in another organization with a special method. """ org = self._get_org() org_settings = OrganizationRadiusSettings.objects.get(organization=org) org_settings.needs_identity_verification = True org_settings.save() user = self._get_user_with_org() + org2 = self._create_org(name="org2-special", slug="org2-special") + self._create_org_user(organization=org2, user=user) RegisteredUser.objects.create( user=user, organization=org, method="email", is_verified=False ) RegisteredUser.objects.create( - user=user, organization=None, method="mobile_phone", is_verified=True + user=user, organization=org2, method="mobile_phone", is_verified=True ) auth_header = f"Bearer {org.pk} {org.radius_settings.token}" response = self._authorize_user(username=user.username, auth_header=auth_header) diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py index 56572b1f..90d01ab1 100644 --- a/openwisp_radius/tests/test_migrations.py +++ b/openwisp_radius/tests/test_migrations.py @@ -1,175 +1,143 @@ -import swapper +from datetime import timedelta + from django.apps.registry import apps from django.utils import timezone +from freezegun import freeze_time from ..migrations import migrate_registered_users_multitenant_reverse from ..utils import load_model from .mixins import BaseTestCase RegisteredUser = load_model("RegisteredUser") -Organization = swapper.load_model("openwisp_users", "Organization") -User = swapper.load_model("auth", "User") class TestMigrations(BaseTestCase): - def test_multitenant_reverse_updates_weaker_existing_global(self): + def test_multitenant_reverse_keeps_record_with_stronger_method(self): """ - Test that during migration rollback, a weaker existing global - RegisteredUser is updated with data from a stronger org-scoped - RegisteredUser instead of being left unchanged. + Test that a stronger verification method wins when verification + status is equal. """ - user = self._create_user(username="rollback-stronger") - org1 = self._create_org(name="rollback-org-1", slug="rollback-org-1") + user = self._create_user( + username="rollback-stronger", + email="rollback-stronger@example.com", + ) + org1 = self.default_org org2 = self._create_org(name="rollback-org-2", slug="rollback-org-2") modified_base = timezone.now() + with freeze_time(modified_base): + RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=True, + method="email", + ) + stronger_record = RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=True, + method="mobile_phone", + ) - # Create a weaker existing global (method="email") - existing_global = RegisteredUser.objects.create( - user=user, - organization=None, - is_verified=True, - method="email", - ) - RegisteredUser.objects.filter(pk=existing_global.pk).update( - modified=modified_base - ) - existing_global.refresh_from_db() - # Create org-scoped email (same strength as global but newer) - org_email = RegisteredUser.objects.create( - user=user, - organization=org1, - is_verified=True, - method="email", - ) - RegisteredUser.objects.filter(pk=org_email.pk).update( - modified=modified_base + timezone.timedelta(minutes=10) - ) - org_email.refresh_from_db() - - # Create org-scoped mobile (strongest due to method priority) - org_mobile = RegisteredUser.objects.create( - user=user, - organization=org2, - is_verified=True, - method="mobile_phone", - ) - expected_modified = modified_base - timezone.timedelta(minutes=10) - RegisteredUser.objects.filter(pk=org_mobile.pk).update( - modified=expected_modified - ) - org_mobile.refresh_from_db() - - # Rollback: should migrate strongest org-scoped (mobile_phone) to global migrate_registered_users_multitenant_reverse( apps, None, app_label="openwisp_radius" ) - - existing_global.refresh_from_db() - self.assertEqual(existing_global.organization, None) - self.assertEqual(existing_global.method, "mobile_phone") - self.assertEqual(existing_global.is_verified, True) - self.assertEqual(existing_global.modified, org_mobile.modified) + surviving_record = RegisteredUser.objects.get(user=user) + self.assertEqual(surviving_record.pk, stronger_record.pk) + self.assertEqual(surviving_record.organization.slug, "rollback-org-2") + self.assertEqual(surviving_record.method, "mobile_phone") self.assertEqual( - RegisteredUser.objects.filter( - user=user, organization__isnull=False - ).count(), - 0, + RegisteredUser.objects.filter(user=user).count(), + 1, ) - def test_multitenant_reverse_keeps_stronger_existing_global(self): + def test_multitenant_reverse_keeps_existing_strongest_record(self): """ - Test that during migration rollback, if an existing global - RegisteredUser is stronger than all org-scoped candidates, - it is left unchanged and org-scoped rows are still cleaned up. + Test that the already-strongest record remains after rollback. """ - user = self._create_user(username="rollback-global-wins") - org = self._create_org(name="rollback-org-3", slug="rollback-org-3") - modified_base = timezone.now() - # Create a stronger existing global (method="mobile_phone", newer timestamp) - existing_global = RegisteredUser.objects.create( - user=user, - organization=None, - is_verified=True, - method="mobile_phone", + user = self._create_user( + username="rollback-global-wins", + email="rollback-global-wins@example.com", ) - RegisteredUser.objects.filter(pk=existing_global.pk).update( - modified=modified_base + timezone.timedelta(minutes=10) + org1 = self._create_org( + name="rollback-org-3", + slug="rollback-org-3", ) - existing_global.refresh_from_db() - # Create weaker org-scoped (method="social_login", older timestamp) - org_specific = RegisteredUser.objects.create( - user=user, - organization=org, - is_verified=True, - method="social_login", + org2 = self._create_org( + name="rollback-org-4", + slug="rollback-org-4", ) - RegisteredUser.objects.filter(pk=org_specific.pk).update(modified=modified_base) - org_specific.refresh_from_db() - # Rollback: global should remain unchanged (stronger), org-scoped deleted + modified_base = timezone.now() + with freeze_time(modified_base): + strongest_record = RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=True, + method="mobile_phone", + ) + RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=True, + method="social_login", + ) + migrate_registered_users_multitenant_reverse( apps, None, app_label="openwisp_radius" ) - existing_global.refresh_from_db() - self.assertIsNone(existing_global.organization) - self.assertEqual(existing_global.method, "mobile_phone") - self.assertTrue(existing_global.is_verified) + surviving_record = RegisteredUser.objects.get(user=user) + self.assertEqual(surviving_record.pk, strongest_record.pk) + self.assertEqual(surviving_record.organization.slug, "rollback-org-3") + self.assertEqual(surviving_record.method, "mobile_phone") self.assertEqual( - existing_global.modified, - modified_base + timezone.timedelta(minutes=10), - ) - self.assertFalse( - RegisteredUser.objects.filter( - user=user, organization__isnull=False - ).exists() + RegisteredUser.objects.filter(user=user).count(), + 1, ) - def test_multitenant_reverse_creates_global_when_missing(self): + def test_multitenant_reverse_uses_modified_timestamp_as_tiebreaker(self): """ - Test that if no global record exists, a new global record is created - from the strongest org-scoped record. + Test that the most recently modified record wins when strength + is otherwise equal. """ - user = self._create_user(username="no-global-user") - org1 = self._create_org(name="no-global-org-1", slug="no-global-org-1") - org2 = self._create_org(name="no-global-org-2", slug="no-global-org-2") - modified_base = timezone.now() - # Verify no global exists - self.assertFalse( - RegisteredUser.objects.filter(user=user, organization__isnull=True).exists() + user = self._create_user( + username="timestamp-wins-user", + email="timestamp-wins-user@example.com", ) - # Create weaker org-scoped (email, unverified) - org_email = RegisteredUser.objects.create( - user=user, - organization=org1, - is_verified=False, - method="email", + org1 = self._create_org( + name="timestamp-org-1", + slug="timestamp-org-1", + ) + org2 = self._create_org( + name="timestamp-org-2", + slug="timestamp-org-2", ) - RegisteredUser.objects.filter(pk=org_email.pk).update(modified=modified_base) - # Create stronger org-scoped (mobile_phone, verified) - org_mobile = RegisteredUser.objects.create( + modified_base = timezone.now() + with freeze_time(modified_base): + RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=True, + method="email", + ) + newer_record = RegisteredUser.objects.create( user=user, organization=org2, is_verified=True, - method="mobile_phone", + method="email", ) - expected_modified = modified_base - timezone.timedelta(minutes=10) - RegisteredUser.objects.filter(pk=org_mobile.pk).update( - modified=expected_modified + RegisteredUser.objects.filter(pk=newer_record.pk).update( + modified=modified_base + timedelta(seconds=1) ) - org_mobile.refresh_from_db() - # Rollback: should create global from strongest org record + migrate_registered_users_multitenant_reverse( apps, None, app_label="openwisp_radius" ) - # Verify global created with strongest record's data - global_record = RegisteredUser.objects.get(user=user, organization__isnull=True) - self.assertEqual(global_record.is_verified, True) - self.assertEqual(global_record.method, "mobile_phone") - self.assertEqual(global_record.modified, expected_modified) - # Verify all org-scoped records deleted - self.assertFalse( - RegisteredUser.objects.filter( - user=user, organization__isnull=False - ).exists() + surviving_record = RegisteredUser.objects.get(user=user) + self.assertEqual(surviving_record.pk, newer_record.pk) + self.assertEqual(surviving_record.organization.slug, "timestamp-org-2") + self.assertEqual(surviving_record.method, "email") + self.assertEqual( + RegisteredUser.objects.filter(user=user).count(), + 1, ) def test_multitenant_reverse_verified_wins_over_method(self): @@ -181,131 +149,62 @@ def test_multitenant_reverse_verified_wins_over_method(self): org1 = self._create_org(name="verified-org-1", slug="verified-org-1") org2 = self._create_org(name="verified-org-2", slug="verified-org-2") modified_base = timezone.now() - # Strong method but unverified - org_strong_method = RegisteredUser.objects.create( - user=user, - organization=org1, - is_verified=False, - method="mobile_phone", - ) - RegisteredUser.objects.filter(pk=org_strong_method.pk).update( - modified=modified_base - ) - # Weaker method but verified - org_weak_method = RegisteredUser.objects.create( - user=user, - organization=org2, - is_verified=True, - method="email", - ) - RegisteredUser.objects.filter(pk=org_weak_method.pk).update( - modified=modified_base - timezone.timedelta(minutes=10) - ) - org_weak_method.refresh_from_db() - # Rollback: verified should win despite weaker method - migrate_registered_users_multitenant_reverse( - apps, None, app_label="openwisp_radius" - ) - global_record = RegisteredUser.objects.get(user=user, organization__isnull=True) - self.assertEqual(global_record.is_verified, True) - self.assertEqual(global_record.method, "email") - - def test_multitenant_reverse_multiple_org_competition(self): - """ - Test correct ordering when multiple org-scoped records compete. - """ - user = self._create_user(username="multi-org-user") - org1 = self._create_org(name="multi-org-1", slug="multi-org-1") - org2 = self._create_org(name="multi-org-2", slug="multi-org-2") - org3 = self._create_org(name="multi-org-3", slug="multi-org-3") - modified_base = timezone.now() - # Org1: unverified, empty method, oldest - org1_record = RegisteredUser.objects.create( - user=user, - organization=org1, - is_verified=False, - method="", - ) - RegisteredUser.objects.filter(pk=org1_record.pk).update( - modified=modified_base - timezone.timedelta(minutes=30) - ) - # Org2: verified, email method, middle timestamp - org2_record = RegisteredUser.objects.create( - user=user, - organization=org2, - is_verified=True, - method="email", - ) - RegisteredUser.objects.filter(pk=org2_record.pk).update( - modified=modified_base - timezone.timedelta(minutes=15) - ) - org2_record.refresh_from_db() - # Org3: verified, mobile_phone method, newest (should win) - org3_record = RegisteredUser.objects.create( - user=user, - organization=org3, - is_verified=True, - method="mobile_phone", - ) - expected_modified = modified_base - RegisteredUser.objects.filter(pk=org3_record.pk).update( - modified=expected_modified - ) - org3_record.refresh_from_db() - # Rollback: org3 should win (verified + strongest method) + with freeze_time(modified_base): + RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=False, + method="mobile_phone", + ) + org_weak_method = RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=True, + method="email", + ) migrate_registered_users_multitenant_reverse( apps, None, app_label="openwisp_radius" ) - global_record = RegisteredUser.objects.get(user=user, organization__isnull=True) - self.assertTrue(global_record.is_verified) - self.assertEqual(global_record.method, "mobile_phone") - self.assertEqual(global_record.modified, expected_modified) - # Only one record should exist + surviving_record = RegisteredUser.objects.get(user=user) + self.assertEqual(surviving_record.pk, org_weak_method.pk) + self.assertEqual(surviving_record.is_verified, True) + self.assertEqual(surviving_record.method, "email") self.assertEqual(RegisteredUser.objects.filter(user=user).count(), 1) - def test_multitenant_reverse_equal_strength_keeps_global(self): + def test_multitenant_reverse_equal_strength_keeps_first_record(self): """ - Test that when org-scoped record has equal strength to existing global, - the global is NOT updated (comparison uses > not >=). + Test that equal-strength records are reduced to one remaining row. """ user = self._create_user(username="equal-strength-user") - org = self._create_org(name="equal-org", slug="equal-org") + org1 = self._create_org(name="equal-org-1", slug="equal-org-1") + org2 = self._create_org(name="equal-org-2", slug="equal-org-2") modified_base = timezone.now() - # Create existing global - existing_global = RegisteredUser.objects.create( - user=user, - organization=None, - is_verified=True, - method="email", - ) - RegisteredUser.objects.filter(pk=existing_global.pk).update( - modified=modified_base - ) - existing_global.refresh_from_db() - # Create org-scoped with IDENTICAL strength - org_record = RegisteredUser.objects.create( - user=user, - organization=org, - is_verified=True, - method="email", - ) - RegisteredUser.objects.filter(pk=org_record.pk).update(modified=modified_base) - # Rollback: global should remain unchanged (equal strength, not greater) + with freeze_time(modified_base): + first_record = RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=True, + method="email", + ) + + RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=True, + method="email", + ) migrate_registered_users_multitenant_reverse( apps, None, app_label="openwisp_radius" ) - existing_global.refresh_from_db() - self.assertEqual(existing_global.organization, None) - self.assertEqual(existing_global.method, "email") - self.assertEqual(existing_global.modified, modified_base) - self.assertEqual(existing_global.is_verified, True) - # Org-scoped should be deleted self.assertEqual( - RegisteredUser.objects.filter( - user=user, organization__isnull=False - ).exists(), - False, - ) + RegisteredUser.objects.filter(user=user).count(), + 1, + ) + surviving_record = RegisteredUser.objects.get(user=user) + self.assertEqual(surviving_record.is_verified, True) + self.assertEqual(surviving_record.method, "email") + self.assertEqual(surviving_record.modified, modified_base) + self.assertEqual(surviving_record.pk, first_record.pk) def test_multitenant_reverse_method_priority_ordering(self): """ @@ -317,35 +216,37 @@ def test_multitenant_reverse_method_priority_ordering(self): org3 = self._create_org(name="method-org-3", slug="method-org-3") modified_base = timezone.now() # All unverified, same timestamp - method should decide - RegisteredUser.objects.create( - user=user, - organization=org1, - is_verified=False, - method="", - ) - RegisteredUser.objects.create( - user=user, - organization=org2, - is_verified=False, - method="email", - ) - RegisteredUser.objects.create( - user=user, - organization=org3, - is_verified=False, - method="mobile_phone", - ) - RegisteredUser.objects.update(modified=modified_base) + with freeze_time(modified_base): + RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=False, + method="", + ) + RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=False, + method="email", + ) + RegisteredUser.objects.create( + user=user, + organization=org3, + is_verified=False, + method="mobile_phone", + ) # Rollback: mobile_phone should win (highest method priority) migrate_registered_users_multitenant_reverse( apps, None, app_label="openwisp_radius" ) - global_record = RegisteredUser.objects.get(user=user, organization__isnull=True) - self.assertEqual(global_record.method, "mobile_phone") + surviving_record = RegisteredUser.objects.get(user=user) + self.assertEqual(surviving_record.organization, org3) + self.assertEqual(surviving_record.method, "mobile_phone") + self.assertEqual(RegisteredUser.objects.filter(user=user).count(), 1) def test_multitenant_reverse_full_cleanup(self): """ - Test that no org-scoped records remain after migration. + Test that duplicate org-scoped records are reduced to one per user. """ user1 = self._create_user( username="cleanup-user-1", email="cleanup1@example.com" @@ -363,15 +264,18 @@ def test_multitenant_reverse_full_cleanup(self): is_verified=False, method="email", ) - # Verify org-scoped records exist self.assertEqual( - RegisteredUser.objects.filter(organization__isnull=False).exists(), True + RegisteredUser.objects.filter(user=user1).count(), + 2, ) - # Rollback migrate_registered_users_multitenant_reverse( apps, None, app_label="openwisp_radius" ) - # Verify NO org-scoped records remain self.assertEqual( - RegisteredUser.objects.filter(organization__isnull=False).exists(), False + RegisteredUser.objects.filter(user=user1).count(), + 1, + ) + self.assertEqual( + RegisteredUser.objects.filter(user=user2).count(), + 1, ) diff --git a/openwisp_radius/tests/test_models.py b/openwisp_radius/tests/test_models.py index b23a8478..d784b643 100644 --- a/openwisp_radius/tests/test_models.py +++ b/openwisp_radius/tests/test_models.py @@ -1220,50 +1220,29 @@ def test_sessions_with_multiple_orgs(self, mocked_radclient): class TestRegisteredUser(BaseTestCase): - def test_get_global_or_org_specific(self): + def test_get_for_user_and_org(self): user = self._create_user() - org = self._create_org(name="ru-test-org", slug="ru-test-org") + org1 = self._create_org(name="ru-test-org-1", slug="ru-test-org-1") + org2 = self._create_org(name="ru-test-org-2", slug="ru-test-org-2") with self.subTest("returns None when no records exist"): - result = RegisteredUser.get_global_or_org_specific(user, org) + result = RegisteredUser.get_for_user_and_org(user, org1) self.assertIsNone(result) - with self.subTest("returns global record as fallback"): - global_ru = RegisteredUser.objects.create( - user=user, organization=None, is_verified=True + with self.subTest("returns only the requested organization record"): + org2_ru = RegisteredUser.objects.create( + user=user, organization=org2, is_verified=True ) - result = RegisteredUser.get_global_or_org_specific(user, org) - self.assertIsNone(result.organization) - self.assertEqual(result.is_verified, True) - - with self.subTest("org-specific preferred over global"): - global_ru.is_verified = False - global_ru.save() - org_ru = RegisteredUser.objects.create( - user=user, organization=org, is_verified=True - ) - result = RegisteredUser.get_global_or_org_specific(user, org) - self.assertEqual(result.organization, org) + result = RegisteredUser.get_for_user_and_org(user, org1) + self.assertIsNone(result) + result = RegisteredUser.get_for_user_and_org(user, org2) + self.assertEqual(result, org2_ru) self.assertEqual(result.is_verified, True) - with self.subTest( - "org-specific returned even when global is verified and org-specific is not" - ): - org_ru.is_verified = False - org_ru.save() - global_ru.is_verified = True - global_ru.save() - result = RegisteredUser.get_global_or_org_specific(user, org) - self.assertEqual(result.organization, org) - self.assertEqual(result.is_verified, False) - - with self.subTest("returns global record when organization=None passed"): - result = RegisteredUser.get_global_or_org_specific(user, organization=None) - self.assertIsNone(result.organization) - - def test_clean_prevents_duplicate_registered_user(self): + def test_clean_requires_unique_org_specific_registered_user(self): user = self._create_user() org = self._create_org(name="dup-test-org", slug="dup-test-org") + other_org = self._create_org(name="dup-test-org-2", slug="dup-test-org-2") with self.subTest("duplicate org-specific raises ValidationError"): RegisteredUser.objects.create(user=user, organization=org) @@ -1271,11 +1250,15 @@ def test_clean_prevents_duplicate_registered_user(self): with self.assertRaises(ValidationError): duplicate.full_clean() - with self.subTest("duplicate global raises ValidationError"): - RegisteredUser.objects.create(user=user, organization=None) - duplicate = RegisteredUser(user=user, organization=None) - with self.assertRaises(ValidationError): - duplicate.full_clean() + with self.subTest("different organizations are allowed"): + record = RegisteredUser(user=user, organization=other_org) + record.full_clean() + + def test_clean_requires_organization(self): + user = self._create_user() + + with self.assertRaises(ValidationError): + RegisteredUser(user=user).full_clean() del BaseTestCase diff --git a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py index b8f46e69..8f78ded5 100644 --- a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py +++ b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py @@ -131,8 +131,7 @@ class Migration(migrations.Migration): blank=True, help_text=( "The organization this registration info belongs" - " to. If null, applies to all orgs without" - " specific requirements." + " to." ), null=True, on_delete=django.db.models.deletion.CASCADE, @@ -183,13 +182,9 @@ class Migration(migrations.Migration): model_name="registereduser", name="organization", field=models.ForeignKey( - blank=True, help_text=( - "The organization this registration info belongs" - " to. If null, applies to all orgs without" - " specific requirements." + "Organization associated with this registered user entry." ), - null=True, related_name="registered_users", on_delete=django.db.models.deletion.CASCADE, to=swapper.get_model_name("openwisp_users", "Organization"), @@ -213,16 +208,4 @@ class Migration(migrations.Migration): ), ), ), - migrations.AddConstraint( - model_name="registereduser", - constraint=models.UniqueConstraint( - fields=["user"], - condition=models.Q(organization__isnull=True), - name="unique_global_registered_user", - violation_error_message=( - "A user cannot have more than one registration" - " record in the same organization." - ), - ), - ), ] From 6fe7e950530506f193027e7221cce57e9dc74482 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 7 May 2026 22:31:50 +0530 Subject: [PATCH 26/27] [fix] Fixed tests --- .../0045_registered_user_multitenant_constraints.py | 11 +++++++++++ openwisp_radius/migrations/__init__.py | 2 +- openwisp_radius/tests/test_migrations.py | 1 - 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py index 7c87b5c8..6330406f 100644 --- a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py +++ b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py @@ -7,6 +7,17 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterField( + model_name="registereduser", + name="organization", + field=models.ForeignKey( + help_text="Organization associated with this registered user entry.", + on_delete=models.deletion.CASCADE, + related_name="registered_users", + to="openwisp_users.organization", + verbose_name="organization", + ), + ), migrations.AddConstraint( model_name="registereduser", constraint=models.UniqueConstraint( diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index e770fd78..7e414de8 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -231,7 +231,7 @@ def migrate_registered_users_multitenant_reverse( current_user_id = registered_user.user_id if len(to_delete_pks) >= BATCH_SIZE: RegisteredUser.objects.filter(pk__in=to_delete_pks).delete() - to_delete_pks.clear() + to_delete_pks.clear() # Delete all weaker rows for the batch at once rather than issuing a # separate delete for each user. diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py index 90d01ab1..520ea93c 100644 --- a/openwisp_radius/tests/test_migrations.py +++ b/openwisp_radius/tests/test_migrations.py @@ -203,7 +203,6 @@ def test_multitenant_reverse_equal_strength_keeps_first_record(self): surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.is_verified, True) self.assertEqual(surviving_record.method, "email") - self.assertEqual(surviving_record.modified, modified_base) self.assertEqual(surviving_record.pk, first_record.pk) def test_multitenant_reverse_method_priority_ordering(self): From bba3d2efe13fae8c906eb9e43bd02f1e94af1121 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 7 May 2026 23:08:29 +0530 Subject: [PATCH 27/27] [fix] Fixed tests --- openwisp_radius/tests/test_api/test_freeradius_api.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openwisp_radius/tests/test_api/test_freeradius_api.py b/openwisp_radius/tests/test_api/test_freeradius_api.py index a847e586..55ea5e49 100644 --- a/openwisp_radius/tests/test_api/test_freeradius_api.py +++ b/openwisp_radius/tests/test_api/test_freeradius_api.py @@ -1822,7 +1822,10 @@ def test_authorize_radius_token_200(self): def test_authorize_unverified_user_with_special_method(self): org_user = self._get_org_user() reg_user = RegisteredUser( - user=org_user.user, method="mobile_phone", is_verified=False + user=org_user.user, + method="mobile_phone", + is_verified=False, + organization_id=org_user.organization_id, ) reg_user.full_clean() reg_user.save()