From 46f8a58fdb7b76f4a6290c2c78b8d7aee1377db2 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 30 Mar 2026 20:15:08 +0530 Subject: [PATCH 01/45] [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 ac095a54..5f216621 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 7010cfc12a60b596fde10f63a04f40fcbe86be8c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 13 Apr 2026 20:18:00 +0530 Subject: [PATCH 02/45] [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 d83228d2b3d082e3d04021725d68d411b2175a17 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Apr 2026 01:17:06 +0530 Subject: [PATCH 03/45] [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 c88a1323b35343dc1b65c6d7b5c92ccb8ea93120 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Apr 2026 01:17:23 +0530 Subject: [PATCH 04/45] [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 1dd7a39570e083cec518af392a35c1979d1a8b50 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Apr 2026 22:45:00 +0530 Subject: [PATCH 05/45] [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 24d39f98602a2c54cfc4bb696ba3552dba746fb5 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 14 Apr 2026 23:56:22 +0530 Subject: [PATCH 06/45] [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 55580f9e934167c74d2615f88f93853bc85e413a Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 15 Apr 2026 20:20:58 +0530 Subject: [PATCH 07/45] [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 5f216621..7d7192be 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 bbcdd72d87d1485b732adfd5825ca5bc69b6709b Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 17 Apr 2026 14:26:16 +0530 Subject: [PATCH 08/45] [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 4cc1dc6977801acf350b00b408511611d63efb0c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 17 Apr 2026 18:17:35 +0530 Subject: [PATCH 09/45] [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 7e76f86103f61382cec756cba390d7734656fd5d Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 20 Apr 2026 17:07:28 +0530 Subject: [PATCH 10/45] [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/45] [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 6e2fb88e73c51cf0bb273f0045099f75ae337282 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 22 Apr 2026 22:40:13 +0530 Subject: [PATCH 12/45] [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 1dc6655a14a29addfda65ccd4b35ea0b17e4a481 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 22 Apr 2026 23:36:09 +0530 Subject: [PATCH 13/45] [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 e1ff16e96ea54e7de7d257ccaa508ca367413386 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 23 Apr 2026 18:00:49 +0530 Subject: [PATCH 14/45] [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 a418450b208c7ee0d4f7abba0bc459b348aa837d Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 24 Apr 2026 17:34:29 +0530 Subject: [PATCH 15/45] [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 7d7192be..7b42df2a 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 6fcec64bbc54791fdd0cc5bb97eab550d2b2c8a8 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 24 Apr 2026 18:01:29 +0530 Subject: [PATCH 16/45] [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 7761257b8eb2fa15fa50f044e49e173a0b9edc7a Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 24 Apr 2026 19:33:25 +0530 Subject: [PATCH 17/45] [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 d52deff6b495c428f837c3977fe4e7d6aae4fb18 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 27 Apr 2026 18:50:23 +0530 Subject: [PATCH 18/45] [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 104a7cd6c8f648b4c21242f631f74a82f5eb8c21 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 29 Apr 2026 20:26:50 +0530 Subject: [PATCH 19/45] [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 7b42df2a..6495766f 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 31132cc3f74baa0ac691a0720e645d011e43e544 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 4 May 2026 17:44:17 +0530 Subject: [PATCH 20/45] [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 6495766f..914379c5 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 b83ca7633581e239c627e2f1ebdef1b907ad69e1 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 4 May 2026 19:06:53 +0530 Subject: [PATCH 21/45] [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 554eccf5b5e3b3a02fd4594d3821ea293d66518f Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 4 May 2026 21:44:24 +0530 Subject: [PATCH 22/45] [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 b61ea0729bb41658cdbfc8aa0620a42005958c6f Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 5 May 2026 18:30:00 +0530 Subject: [PATCH 23/45] [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 c1de2d776c32d73b07bfcc73613814eafcf1eb9c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 7 May 2026 16:37:10 +0530 Subject: [PATCH 24/45] [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 08a7bdf2c255260ff41c65b454fc1645358078ee Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 7 May 2026 22:31:50 +0530 Subject: [PATCH 25/45] [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 bfcf393501ec9b0b96d5fe47db9fbaa08d0e7e79 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 7 May 2026 23:08:29 +0530 Subject: [PATCH 26/45] [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() From f5c6d921f601bc3e4d76193fe0bebef6519bd13b Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 8 May 2026 18:04:19 +0530 Subject: [PATCH 27/45] [fix] Fixed migrations --- .../0043_registereduser_add_uuid.py | 22 ++ ...registered_user_multitenant_constraints.py | 11 - openwisp_radius/migrations/__init__.py | 190 ++++++++++++------ 3 files changed, 145 insertions(+), 78 deletions(-) diff --git a/openwisp_radius/migrations/0043_registereduser_add_uuid.py b/openwisp_radius/migrations/0043_registereduser_add_uuid.py index 3cff024b..d280f8ae 100644 --- a/openwisp_radius/migrations/0043_registereduser_add_uuid.py +++ b/openwisp_radius/migrations/0043_registereduser_add_uuid.py @@ -62,6 +62,17 @@ class Migration(migrations.Migration): verbose_name="organization", ), ), + migrations.AddConstraint( + model_name="registereduser", + 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." + ), + ), + ), ], database_operations=[ migrations.CreateModel( @@ -139,6 +150,17 @@ class Migration(migrations.Migration): options={ "verbose_name": "Registration Information", "verbose_name_plural": "Registration Information", + "constraints": [ + 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.RunPython( diff --git a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py index 6330406f..e0710200 100644 --- a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py +++ b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py @@ -18,15 +18,4 @@ class Migration(migrations.Migration): verbose_name="organization", ), ), - migrations.AddConstraint( - model_name="registereduser", - 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." - ), - ), - ), ] diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index 7e414de8..bcc7b5e1 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -1,11 +1,10 @@ import uuid -from collections import defaultdict import swapper 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 django.db.models import Case, IntegerField, Prefetch, Value, When from ..utils import create_default_groups @@ -35,12 +34,6 @@ 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 @@ -58,22 +51,6 @@ def _registered_user_method_priority_case(): ) -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, @@ -145,52 +122,131 @@ def copy_registered_users_ctcr_reverse( def migrate_registered_users_multitenant_forward( apps, schema_editor, app_label, extra_fields=() ): - RegisteredUser = apps.get_model(app_label, "RegisteredUser") - OrganizationUser = get_swapped_model(apps, "openwisp_users", "OrganizationUser") - - queryset = RegisteredUser.objects.filter(organization__isnull=True).order_by( - "user_id" + """ + Expand legacy org-less RegisteredUser rows into organization-specific rows. + + Before this migration, RegisteredUser is effectively single-tenant and users + are expected to have at most one row where organization IS NULL. That row is + treated as the template for all organization-specific rows created during the + migration. + + For each user, the migration: + 1. Finds the org-less RegisteredUser row. + 2. Creates one RegisteredUser per OrganizationUser membership. + 3. Deletes the original org-less row. + + Implementation notes: + - Assumes each user has at most one org-less RegisteredUser row. + - Prioritizes readability and explicit control flow over aggressive SQL/JOIN + optimization. + - Avoids JOIN-based filtering to keep migration assumptions visible in Python + and reduce duplicate-row/DISTINCT complexity. + - Uses iterator(), prefetch_related(), bulk_create(), and batched deletes to + remain memory bounded while processing large datasets. + """ + User = apps.get_model(settings.AUTH_USER_MODEL) + RegisteredUser = apps.get_model( + app_label, + "RegisteredUser", ) - 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") + OrganizationUser = get_swapped_model( + apps, + "openwisp_users", + "OrganizationUser", + ) + + queryset = User.objects.prefetch_related( + Prefetch( + "registered_users", + queryset=RegisteredUser.objects.only( + "id", + "user_id", + "organization_id", + "method", + "is_verified", + "modified", + *extra_fields, + ), + # Store prefetched objects directly as a Python list to avoid + # additional queryset evaluation during iteration. + to_attr="prefetched_registered_users", + ), + Prefetch( + "openwisp_users_organizationuser", + queryset=OrganizationUser.objects.only( + "user_id", + "organization_id", + ), + to_attr="organization_memberships", + ), + ).order_by("id") + + to_create = [] + for user in queryset.iterator(chunk_size=BATCH_SIZE): + # Locate the legacy org-less RegisteredUser row that acts as the source + # template for new organization-specific rows. + # + # We intentionally do this in Python instead of SQL because: + # + # - the prefetched list is expected to be extremely small + # (ideally, it will contain at most one item due to the migration invariant) + # - it keeps migration assumptions explicit, + # - and avoids introducing JOIN + DISTINCT complexity. + base_registered_user = next( + ( + registered_user + for registered_user in user.prefetched_registered_users + if registered_user.organization_id is None + ), + None, ) + # Users without a legacy org-less RegisteredUser row require no work. + if not base_registered_user: + continue + + # Create one RegisteredUser row per organization membership. + for membership in user.organization_memberships: + copied = RegisteredUser( + id=uuid.uuid4(), + user_id=user.id, + organization_id=membership.organization_id, + method=base_registered_user.method, + is_verified=base_registered_user.is_verified, + **_registered_user_extra_kwargs( + base_registered_user, + extra_fields, + ), + ) + # Preserve the original modification timestamp because this migration + # reshapes existing data rather than creating a logically new + # verification state. + copied.modified = base_registered_user.modified + to_create.append(copied) + + # Flush inserts in batches to avoid holding too many unsaved model + # instances in memory. + if len(to_create) >= BATCH_SIZE: + _flush_bulk_create( + RegisteredUser, + to_create, + ) + + _flush_bulk_create( + RegisteredUser, + to_create, + ) - to_create = [] - for registered_user in batch: - organization_ids = sorted(memberships.get(registered_user.user_id, ())) - if not organization_ids: - continue - 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) + # Delete all remaining legacy org-less RegisteredUser rows. + # + # This covers: + # 1. Users whose org-less row was expanded into org-specific rows above. + # 2. Users with an org-less row but zero organization memberships. + # These users have no org-specific rows to migrate to, and keeping + # an org-less row would violate the new (user, organization) unique + # constraint, so the row is intentionally cleaned up here. + RegisteredUser.objects.filter( + organization__isnull=True, + ).delete() def migrate_registered_users_multitenant_reverse( From b3f99ba062698e89569c3a9154eed0f622a8e7af Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 8 May 2026 19:05:47 +0530 Subject: [PATCH 28/45] [fix] Added autocompleted organization field in RegisteredUserinline --- openwisp_radius/admin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 92775786..1c4e4820 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -559,6 +559,7 @@ class RegisteredUserInline(StackedInline): extra = 0 readonly_fields = ("modified",) fields = ("organization", "method", "is_verified", "modified") + autocomplete_fields = ("organization",) def has_delete_permission(self, request, obj=None): return False From 93d39da10f30fe22ddafa25d1772048adff358e0 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 12 May 2026 13:15:59 +0530 Subject: [PATCH 29/45] [ci] Removed openwisp-users override --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6aeb276..339692e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,7 +74,6 @@ 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 --force-reinstall "https://github.com/openwisp/openwisp-users/tarball/issues/497-export-users" pip install ${{ matrix.django-version }} - name: Start InfluxDB and Redis container From 494c9fc3b0a38f53fd53bdaed33c3fdb00399676 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 12 May 2026 15:43:20 +0530 Subject: [PATCH 30/45] [fix] Fixes by @coderabbitai --- openwisp_radius/admin.py | 5 ++- openwisp_radius/api/serializers.py | 2 +- openwisp_radius/api/utils.py | 2 +- openwisp_radius/api/views.py | 2 +- openwisp_radius/base/models.py | 10 ++--- .../integrations/monitoring/tasks.py | 3 +- .../monitoring/tests/test_metrics.py | 31 ++++++++++------ openwisp_radius/migrations/__init__.py | 1 + openwisp_radius/saml/views.py | 2 +- openwisp_radius/social/views.py | 2 +- openwisp_radius/tests/test_migrations.py | 37 +++++++++++++++++++ 11 files changed, 70 insertions(+), 27 deletions(-) diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 1c4e4820..4f664412 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -575,9 +575,10 @@ def has_delete_permission(self, request, obj=None): def get_is_verified(self, obj): try: - if not obj.registered_users.exists(): + is_verifieds = obj.registered_users.values_list("is_verified", flat=True) + if not len(is_verifieds): value = "unknown" - elif obj.registered_users.filter(is_verified=True).exists(): + elif any(is_verifieds): value = "yes" else: value = "no" diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 8b878f4a..7cbeb211 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -689,7 +689,7 @@ def save(self, request): self.custom_signup(request, user) # create a RegisteredUser object for every user that registers through API org = self.context["view"].organization - RegisteredUser.objects.get_or_create( + RegisteredUser.get_or_create_for_user_and_org( user=user, organization=org, defaults={"method": self.validated_data["method"]}, diff --git a/openwisp_radius/api/utils.py b/openwisp_radius/api/utils.py index 447ca7c5..d2b5e1f8 100644 --- a/openwisp_radius/api/utils.py +++ b/openwisp_radius/api/utils.py @@ -31,7 +31,7 @@ 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, organization=None): + def is_identity_verified_strong(self, user, organization): reg_user = None # We use all() to utilize the prefetch cache, otherwise # it would cause an additional query to fetch the registered user diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 9d86add9..138396e2 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -343,7 +343,7 @@ def validate_membership(self, user): OrganizationUser.objects.get_or_create( user=user, organization=self.organization ) - RegisteredUser.objects.get_or_create( + RegisteredUser.get_or_create_for_user_and_org( user=user, organization=self.organization, defaults={"method": "pending_verification"}, diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index 300fcc87..8d9c68e0 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1701,18 +1701,14 @@ def get_for_user_and_org(cls, user, organization): def unverify_inactive_users(cls): if not app_settings.UNVERIFY_INACTIVE_USERS: return - # Exclude users who have unspecified, manual, email, or pending_verification + # Exclude users who have unspecified, manual, or email # 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", "pending_verification"] - ).filter( + cls.objects.exclude(method__in=["", "manual", "email"]).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 e46affd3..1b1ffb11 100644 --- a/openwisp_radius/integrations/monitoring/tasks.py +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -117,8 +117,9 @@ def _write_user_signup_metrics_for_orgs(metric_key): # The query returns a tuple of organization_id, registration_method and # count of users who registered with that organization and method. registered_users_query = RegisteredUser.objects.exclude( + method="pending_verification" + ).exclude( user__openwisp_users_organizationuser__created__gt=end_time, - method="pending_verification", ) if metric_key == "user_signups": diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index 2cc598ee..e36436a8 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -27,16 +27,23 @@ def _read_chart(self, chart, **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", {}), - ) + """ + Ensure that pending_verification users do not contribute + to metric outputs. + + This validates both: + - trace-level values (time series data) + - summary-level aggregation + """ + self.assertEqual(points["traces"][0][1][-1], 0) + summary = points.get("summary", {}) + # Summary should not contain any positive counts + for key, value in summary.items(): + self.assertEqual( + value, + 0, + f"pending_verification leaked into summary for key={key}", + ) def _create_registered_user(self, **kwargs): options = { @@ -623,7 +630,7 @@ def test_pending_verification_excluded_from_metrics(self): user_signup_chart = user_signup_metric.chart_set.first() 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.assertEqual(len(org_points["traces"]), 0) self._assert_pending_verification_excluded(all_points) total_user_signup_chart = total_user_signup_metric.chart_set.first() @@ -633,5 +640,5 @@ def test_pending_verification_excluded_from_metrics(self): all_points = self._read_chart( total_user_signup_chart, organization_id=["__all__"] ) - self._assert_pending_verification_excluded(org_points) + self.assertEqual(len(org_points["traces"]), 0) self._assert_pending_verification_excluded(all_points) diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index bcc7b5e1..b0821e01 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -44,6 +44,7 @@ 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="pending_verification", then=Value(-1)), When(method="", then=Value(0)), When(method="email", then=Value(1)), default=Value(2), diff --git a/openwisp_radius/saml/views.py b/openwisp_radius/saml/views.py index 2e953518..9df41588 100644 --- a/openwisp_radius/saml/views.py +++ b/openwisp_radius/saml/views.py @@ -73,7 +73,7 @@ def post_login_hook(self, request, user, session_info): orgUser = OrganizationUser(organization=org, user=user) orgUser.full_clean() orgUser.save() - registered_user, created = RegisteredUser.objects.get_or_create( + registered_user, created = RegisteredUser.get_or_create_for_user_and_org( user=user, organization=org, defaults={ diff --git a/openwisp_radius/social/views.py b/openwisp_radius/social/views.py index 21db4fe2..3d84351e 100644 --- a/openwisp_radius/social/views.py +++ b/openwisp_radius/social/views.py @@ -48,7 +48,7 @@ def authorize(self, request, org, *args, **kwargs): orgUser = OrganizationUser(organization=org, user=user) orgUser.full_clean() orgUser.save() - registered_user, created = RegisteredUser.objects.get_or_create( + registered_user, created = RegisteredUser.get_or_create_for_user_and_org( user=user, organization=org, defaults={"method": "social_login", "is_verified": False}, diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py index 520ea93c..00afc39d 100644 --- a/openwisp_radius/tests/test_migrations.py +++ b/openwisp_radius/tests/test_migrations.py @@ -243,6 +243,43 @@ def test_multitenant_reverse_method_priority_ordering(self): self.assertEqual(surviving_record.method, "mobile_phone") self.assertEqual(RegisteredUser.objects.filter(user=user).count(), 1) + def test_multitenant_reverse_pending_verification_method_ignored( + self, + ): + user = self._create_user( + username="pending-vs-strong", + email="pending-vs-strong@example.com", + ) + org1 = self._create_org( + name="pending-org-1", + slug="pending-org-1", + ) + org2 = self._create_org( + name="pending-org-2", + slug="pending-org-2", + ) + modified_base = timezone.now() + with freeze_time(modified_base): + RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=False, + method="pending_verification", + ) + strong_record = RegisteredUser.objects.create( + user=user, + organization=org2, + is_verified=False, + method="mobile_phone", + ) + migrate_registered_users_multitenant_reverse( + apps, None, app_label="openwisp_radius" + ) + surviving_record = RegisteredUser.objects.get(user=user) + self.assertEqual(surviving_record.pk, strong_record.pk) + 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 duplicate org-scoped records are reduced to one per user. From 1322a9fffd1d7dfaa6a529bd73041ff5f73344a5 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 13 May 2026 16:52:42 +0530 Subject: [PATCH 31/45] [fix] Made requested changes --- docs/user/rest-api.rst | 4 +- openwisp_radius/admin.py | 26 +++++++- openwisp_radius/api/freeradius_views.py | 48 +++++---------- .../integrations/monitoring/tasks.py | 2 +- .../monitoring/tests/test_metrics.py | 59 +++++++++++++++++++ openwisp_radius/saml/views.py | 9 +++ openwisp_radius/social/views.py | 3 + openwisp_radius/tests/test_admin.py | 18 ++++++ openwisp_radius/tests/test_migrations.py | 45 ++++++++++---- openwisp_radius/tests/test_saml/test_views.py | 29 +++++++++ openwisp_radius/tests/test_social.py | 20 +++++++ .../0032_registered_user_multitenant.py | 13 ++++ tests/openwisp2/sample_radius/tests.py | 6 ++ 13 files changed, 233 insertions(+), 49 deletions(-) diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst index 68ed71a3..f4664342 100644 --- a/docs/user/rest-api.rst +++ b/docs/user/rest-api.rst @@ -803,8 +803,8 @@ Param Description phone_number string ============ =========== -Update Registered User Method -+++++++++++++++++++++++++++++ +Update user registration method ++++++++++++++++++++++++++++++++ **Requires the user auth token (Bearer Token)**. diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 4f664412..af55f708 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.db.models import Prefetch from django.forms.models import BaseInlineFormSet from django.http import HttpResponseRedirect from django.templatetags.static import static @@ -571,12 +572,32 @@ def has_delete_permission(self, request, obj=None): PhoneTokenInline, ] UserAdmin.list_filter += (RegisteredUserFilter, "registered_users__method") +user_admin_get_queryset = UserAdmin.get_queryset + + +def get_queryset(self, request): + queryset = user_admin_get_queryset(self, request) + return queryset.prefetch_related( + Prefetch( + "registered_users", + queryset=RegisteredUser.objects.only("user_id", "is_verified"), + to_attr="prefetched_registered_users", + ) + ) def get_is_verified(self, obj): try: - is_verifieds = obj.registered_users.values_list("is_verified", flat=True) - if not len(is_verifieds): + prefetched_registered_users = getattr(obj, "prefetched_registered_users", None) + if prefetched_registered_users is not None: + is_verifieds = [ + reg_user.is_verified for reg_user in prefetched_registered_users + ] + else: + is_verifieds = list( + obj.registered_users.values_list("is_verified", flat=True) + ) + if not is_verifieds: value = "unknown" elif any(is_verifieds): value = "yes" @@ -588,6 +609,7 @@ def get_is_verified(self, obj): return mark_safe(f'{value}') +UserAdmin.get_queryset = get_queryset UserAdmin.get_is_verified = get_is_verified UserAdmin.get_is_verified.short_description = _("Verified") UserAdmin.list_display.insert(3, "get_is_verified") diff --git a/openwisp_radius/api/freeradius_views.py b/openwisp_radius/api/freeradius_views.py index 25404e83..977179c5 100644 --- a/openwisp_radius/api/freeradius_views.py +++ b/openwisp_radius/api/freeradius_views.py @@ -406,45 +406,25 @@ 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 + registered_user = Q(registered_users__organization_id=organization_id) + is_verified = Q(registered_users__is_verified=True) AUTHORIZE_UNVERIFIED = registration.AUTHORIZE_UNVERIFIED - # 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 - - # 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) + return is_active & registered_user & 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: + return ( + is_active + & registered_user + & (is_verified | Q(registered_users__method__in=AUTHORIZE_UNVERIFIED)) + ) def authenticate_user(self, request, user, password): """ diff --git a/openwisp_radius/integrations/monitoring/tasks.py b/openwisp_radius/integrations/monitoring/tasks.py index 1b1ffb11..a528b51e 100644 --- a/openwisp_radius/integrations/monitoring/tasks.py +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -128,7 +128,7 @@ def _write_user_signup_metrics_for_orgs(metric_key): user__openwisp_users_organizationuser__created__lte=end_time, ) registered_users = registered_users_query.values_list( - "user__openwisp_users_organizationuser__organization_id", "method" + "organization_id", "method" ).annotate(count=Count("user_id", distinct=True)) # There could be users which were manually created (e.g. superuser) diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index e36436a8..0b62234c 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -642,3 +642,62 @@ def test_pending_verification_excluded_from_metrics(self): ) self.assertEqual(len(org_points["traces"]), 0) self._assert_pending_verification_excluded(all_points) + + def test_write_user_registration_metrics_uses_org_specific_methods(self): + """ + Ensure organization metrics use the registration method associated + with that specific organization membership. + + Scenario: + - One user belongs to two organizations. + - The user has one RegisteredUser row per organization. + - Each RegisteredUser uses a different registration method. + + Expected behavior: + - Global metrics aggregate both methods. + - Each organization only counts its own method. + """ + from ..tasks import write_user_registration_metrics + + def _get_metric_traces(metric_key, organization_id): + chart = self.metric_model.objects.get(key=metric_key).chart_set.first() + points = self._read_chart( + chart, + organization_id=[str(organization_id)], + ) + return {trace_name: values[-1] for trace_name, values in points["traces"]} + + cache.clear() + create_general_metrics(None, None) + org1 = self._get_org() + org2 = self._create_org(name="org2", slug="org2") + user = self._create_user() + self._create_org_user(user=user, organization=org1) + self._create_org_user(user=user, organization=org2) + self._create_registered_user( + user=user, + organization=org1, + method="mobile_phone", + ) + self._create_registered_user( + user=user, + organization=org2, + method="email", + ) + write_user_registration_metrics.delay() + for metric_key in ["user_signups", "tot_user_signups"]: + all_points = _get_metric_traces(metric_key, "__all__") + org1_points = _get_metric_traces(metric_key, org1.pk) + org2_points = _get_metric_traces(metric_key, org2.pk) + + # Global metrics aggregate registrations from all organizations. + self.assertEqual(all_points.get("mobile_phone", 0), 1) + self.assertEqual(all_points.get("email", 0), 1) + + # org1 only counts its own registration method. + self.assertEqual(org1_points.get("mobile_phone", 0), 1) + self.assertEqual(org1_points.get("email", 0), 0) + + # org2 only counts its own registration method. + self.assertEqual(org2_points.get("email", 0), 1) + self.assertEqual(org2_points.get("mobile_phone", 0), 0) diff --git a/openwisp_radius/saml/views.py b/openwisp_radius/saml/views.py index 9df41588..58f3c04d 100644 --- a/openwisp_radius/saml/views.py +++ b/openwisp_radius/saml/views.py @@ -81,6 +81,15 @@ def post_login_hook(self, request, user, session_info): "is_verified": app_settings.SAML_IS_VERIFIED, }, ) + if ( + not created + and registered_user.method == "pending_verification" + and not registered_user.is_verified + ): + registered_user.method = "saml" + registered_user.is_verified = app_settings.SAML_IS_VERIFIED + registered_user.full_clean() + registered_user.save() if created and user.email: # The user is just created, it will not have an email address try: diff --git a/openwisp_radius/social/views.py b/openwisp_radius/social/views.py index 3d84351e..f96a6791 100644 --- a/openwisp_radius/social/views.py +++ b/openwisp_radius/social/views.py @@ -54,6 +54,9 @@ def authorize(self, request, org, *args, **kwargs): defaults={"method": "social_login", "is_verified": False}, ) if not created: + if registered_user.method == "pending_verification": + registered_user.method = "social_login" + registered_user.is_verified = False registered_user.full_clean() registered_user.save() diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index 4e430f8e..3bb3a70f 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -6,6 +6,8 @@ from django.contrib.auth.models import Permission from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured +from django.db import connection +from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -1509,6 +1511,22 @@ def get_expected_html(value): self.assertContains(response, get_expected_html("no")) self.assertContains(response, get_expected_html("unknown")) + def test_get_is_verified_user_admin_list_avoids_nplus1_queries(self): + app_label = User._meta.app_label + path = reverse(f"admin:{app_label}_user_changelist") + # Create users + for i in range(5): + user = self._create_user(username=f"user-{i}", email=f"user-{i}@test.com") + RegisteredUser.objects.create( + user=user, + organization=self.default_org, + method="mobile_phone", + is_verified=(i % 2 == 0), + ) + with self.assertNumQueries(8): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + def test_registered_user_filter(self): unknown = User.objects.first() self.assertIsNotNone(unknown) diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py index 00afc39d..af3c45f6 100644 --- a/openwisp_radius/tests/test_migrations.py +++ b/openwisp_radius/tests/test_migrations.py @@ -1,6 +1,7 @@ from datetime import timedelta from django.apps.registry import apps +from django.db import connection from django.utils import timezone from freezegun import freeze_time @@ -12,6 +13,30 @@ class TestMigrations(BaseTestCase): + app_label = "openwisp_radius" + + def test_registered_user_organization_column_is_not_nullable(self): + registered_user_model = load_model("RegisteredUser") + table_name = registered_user_model._meta.db_table + column_name = registered_user_model._meta.get_field("organization").column + with connection.cursor() as cursor: + columns = connection.introspection.get_table_description( + cursor, + table_name, + ) + column = next( + (col for col in columns if col.name == column_name), + None, + ) + self.assertIsNotNone( + column, + f"Column '{column_name}' not found in '{table_name}'", + ) + self.assertFalse( + column.null_ok, + f"Column '{table_name}.{column_name}' must be NOT NULL at DB level", + ) + def test_multitenant_reverse_keeps_record_with_stronger_method(self): """ Test that a stronger verification method wins when verification @@ -39,7 +64,7 @@ def test_multitenant_reverse_keeps_record_with_stronger_method(self): ) migrate_registered_users_multitenant_reverse( - apps, None, app_label="openwisp_radius" + apps, None, app_label=self.app_label ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, stronger_record.pk) @@ -55,8 +80,8 @@ def test_multitenant_reverse_keeps_existing_strongest_record(self): Test that the already-strongest record remains after rollback. """ user = self._create_user( - username="rollback-global-wins", - email="rollback-global-wins@example.com", + username="rollback-strongest-wins", + email="rollback-strongest-wins@example.com", ) org1 = self._create_org( name="rollback-org-3", @@ -82,7 +107,7 @@ def test_multitenant_reverse_keeps_existing_strongest_record(self): ) migrate_registered_users_multitenant_reverse( - apps, None, app_label="openwisp_radius" + apps, None, app_label=self.app_label ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, strongest_record.pk) @@ -129,7 +154,7 @@ def test_multitenant_reverse_uses_modified_timestamp_as_tiebreaker(self): ) migrate_registered_users_multitenant_reverse( - apps, None, app_label="openwisp_radius" + apps, None, app_label=self.app_label ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, newer_record.pk) @@ -163,7 +188,7 @@ def test_multitenant_reverse_verified_wins_over_method(self): method="email", ) migrate_registered_users_multitenant_reverse( - apps, None, app_label="openwisp_radius" + apps, None, app_label=self.app_label ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, org_weak_method.pk) @@ -194,7 +219,7 @@ def test_multitenant_reverse_equal_strength_keeps_first_record(self): method="email", ) migrate_registered_users_multitenant_reverse( - apps, None, app_label="openwisp_radius" + apps, None, app_label=self.app_label ) self.assertEqual( RegisteredUser.objects.filter(user=user).count(), @@ -236,7 +261,7 @@ def test_multitenant_reverse_method_priority_ordering(self): ) # Rollback: mobile_phone should win (highest method priority) migrate_registered_users_multitenant_reverse( - apps, None, app_label="openwisp_radius" + apps, None, app_label=self.app_label ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.organization, org3) @@ -273,7 +298,7 @@ def test_multitenant_reverse_pending_verification_method_ignored( method="mobile_phone", ) migrate_registered_users_multitenant_reverse( - apps, None, app_label="openwisp_radius" + apps, None, app_label=self.app_label ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, strong_record.pk) @@ -305,7 +330,7 @@ def test_multitenant_reverse_full_cleanup(self): 2, ) migrate_registered_users_multitenant_reverse( - apps, None, app_label="openwisp_radius" + apps, None, app_label=self.app_label ) self.assertEqual( RegisteredUser.objects.filter(user=user1).count(), diff --git a/openwisp_radius/tests/test_saml/test_views.py b/openwisp_radius/tests/test_saml/test_views.py index adcaf1fd..e482f03a 100644 --- a/openwisp_radius/tests/test_saml/test_views.py +++ b/openwisp_radius/tests/test_saml/test_views.py @@ -12,6 +12,7 @@ from djangosaml2.utils import get_session_id_from_saml2, saml2_from_httpredirect_request from rest_framework.authtoken.models import Token +from openwisp_radius import settings as app_settings from openwisp_radius.saml.utils import get_url_or_path from openwisp_users.tests.utils import TestOrganizationMixin from openwisp_utils.tests import capture_any_output @@ -150,6 +151,34 @@ def test_relay_state_relative_path(self): query_params = parse_qs(urlparse(response.url).query) self._post_successful_auth_assertions(query_params, org_slug) + @capture_any_output() + def test_pending_verification_registered_user_updated_for_org(self): + org = Organization.objects.get(slug="default") + user = self._create_user(username="test-user", email="org_user@example.com") + registered_user = RegisteredUser.objects.create( + user=user, + organization=org, + method="pending_verification", + is_verified=False, + ) + relay_state = self._get_relay_state( + redirect_url="https://captive-portal.example.com", org_slug="default" + ) + saml_response, relay_state = self._get_saml_response_for_acs_view(relay_state) + response = self.client.post( + reverse("radius:saml2_acs"), + { + "SAMLResponse": self.b64_for_post(saml_response), + "RelayState": relay_state, + }, + ) + self.assertEqual(response.status_code, 302) + registered_users = RegisteredUser.objects.filter(user=user, organization=org) + self.assertEqual(registered_users.count(), 1) + registered_user.refresh_from_db() + self.assertEqual(registered_user.method, "saml") + self.assertEqual(registered_user.is_verified, app_settings.SAML_IS_VERIFIED) + @capture_any_output() def test_user_registered_with_non_saml_method(self): org = Organization.objects.get(slug="default") diff --git a/openwisp_radius/tests/test_social.py b/openwisp_radius/tests/test_social.py index 07454792..da663faf 100644 --- a/openwisp_radius/tests/test_social.py +++ b/openwisp_radius/tests/test_social.py @@ -110,6 +110,26 @@ def test_redirect_cp_301(self): # so this should be always False when users sign up with this method self.assertEqual(reg_user.is_verified, False) + def test_pending_verification_registered_user_updated_for_org(self): + user = self._create_social_user() + registered_user = RegisteredUser.objects.create( + user=user, + organization=self.default_org, + method="pending_verification", + is_verified=False, + ) + self.client.force_login(user) + url = self.get_url() + response = self.client.get(url, {"cp": "http://wifi.openwisp.org/cp"}) + self.assertEqual(response.status_code, 302) + registered_users = RegisteredUser.objects.filter( + user=user, organization=self.default_org + ) + self.assertEqual(registered_users.count(), 1) + registered_user.refresh_from_db() + self.assertEqual(registered_user.method, "social_login") + self.assertEqual(registered_user.is_verified, False) + def test_authorize_using_radius_user_token_200(self): self.test_redirect_cp_301() rad_token = RadiusToken.objects.filter(user__username="socialuser").first() 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 8f78ded5..82b97682 100644 --- a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py +++ b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py @@ -208,4 +208,17 @@ class Migration(migrations.Migration): ), ), ), + migrations.AlterField( + model_name="registereduser", + name="organization", + field=models.ForeignKey( + help_text=( + "Organization associated with this registered user entry." + ), + 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/tests/openwisp2/sample_radius/tests.py b/tests/openwisp2/sample_radius/tests.py index 6878dcd0..e987742e 100644 --- a/tests/openwisp2/sample_radius/tests.py +++ b/tests/openwisp2/sample_radius/tests.py @@ -31,6 +31,7 @@ TestCSVUpload as BaseTestCSVUpload, ) from openwisp_radius.tests.test_commands import TestCommands as BaseTestCommands +from openwisp_radius.tests.test_migrations import TestMigrations as BaseTestMigrations from openwisp_radius.tests.test_models import TestNas as BaseTestNas from openwisp_radius.tests.test_models import ( TestPrivateCsvFile as BaseTestPrivateCsvFile, @@ -185,6 +186,10 @@ class TestLoginView(BaseTestLoginView): pass +class TestMigrations(BaseTestMigrations): + app_label = "sample_radius" + + del BaseTestAdmin del BaseTestApi del BaseTestFreeradiusApi @@ -214,3 +219,4 @@ class TestLoginView(BaseTestLoginView): del BaseTestUpgradeFromDjangoFreeradius del BaseTestAssertionConsumerServiceView del BaseTestLoginView +del BaseTestMigrations From 31a674043787d55a78aa46ff15a9402723525cd3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 14 May 2026 00:37:24 +0530 Subject: [PATCH 32/45] [fix] Made PhoneToken multi-tenant --- openwisp_radius/api/freeradius_views.py | 2 +- openwisp_radius/api/views.py | 9 +- openwisp_radius/base/models.py | 17 +- .../0043_registereduser_add_uuid.py | 13 ++ .../0044_registered_user_multitenant_data.py | 9 ++ ...registered_user_multitenant_constraints.py | 17 +- openwisp_radius/migrations/__init__.py | 60 ++++++++ openwisp_radius/saml/views.py | 33 +++- openwisp_radius/social/views.py | 4 +- openwisp_radius/tests/test_admin.py | 2 - .../tests/test_api/test_phone_verification.py | 55 ++++++- .../tests/test_api/test_rest_token.py | 15 +- openwisp_radius/tests/test_migrations.py | 72 ++++++++- openwisp_radius/tests/test_saml/test_views.py | 145 +++++++++++++++++- openwisp_radius/tests/test_token.py | 16 +- .../0032_registered_user_multitenant.py | 26 ++++ 16 files changed, 450 insertions(+), 45 deletions(-) diff --git a/openwisp_radius/api/freeradius_views.py b/openwisp_radius/api/freeradius_views.py index 977179c5..acf10621 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 Exists, OuterRef, Q +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from django_filters import rest_framework as filters from django_filters.rest_framework import DjangoFilterBackend diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 138396e2..581d83d3 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -640,6 +640,7 @@ def create(self, *args, **kwargs): phone_number = request.data.get("phone_number", request.user.phone_number) phone_token = PhoneToken( user=request.user, + organization=self.organization, ip=self.get_ident(request), phone_number=phone_number, ) @@ -671,6 +672,7 @@ def enforce_sms_request_cooldown(self, cooldown, phone_number): last_phone_token = ( PhoneToken.objects.filter( user=self.request.user, + organization=self.organization, phone_number=phone_number, created__gt=datetime_now - timezone.timedelta(seconds=cooldown), ) @@ -713,6 +715,7 @@ def get(self, request, *args, **kwargs): self.validate_membership(user) is_active = PhoneToken.objects.filter( user=request.user, + organization=self.organization, phone_number=user.phone_number, valid_until__gte=timezone.now(), verified=False, @@ -749,7 +752,11 @@ def post(self, request, *args, **kwargs): self.validate_membership(user) serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) - phone_token = PhoneToken.objects.filter(user=user).order_by("-created").first() + phone_token = ( + PhoneToken.objects.filter(user=user, organization=self.organization) + .order_by("-created") + .first() + ) if not phone_token: return self._error_response( _("No verification code found in the system for this user.") diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index 8d9c68e0..9def851e 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1466,7 +1466,7 @@ def delete_cache(self, *args, **kwargs): cache.delete(f"ip-{self.organization.pk}") -class AbstractPhoneToken(TimeStampedEditableModel): +class AbstractPhoneToken(OrgMixin, TimeStampedEditableModel): """ Phone Verification Token (sent via SMS) """ @@ -1555,15 +1555,13 @@ def save(self, *args, **kwargs): return result def send_token(self): - OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") - org_user = OrganizationUser.objects.filter(user=self.user).first() - if not org_user: + if self.organization is None: raise exceptions.NoOrgException( _("The user {user} is not member of any organization").format( user=self.user ) ) - org_radius_settings = org_user.organization.radius_settings + org_radius_settings = self.organization.radius_settings message = _(org_radius_settings.sms_message).format( organization=org_radius_settings.organization.name, code=self.token ) @@ -1622,19 +1620,12 @@ def __check(self, token, organization=None): return token == self.token -class AbstractRegisteredUser(UUIDModel): +class AbstractRegisteredUser(UUIDModel, OrgMixin): user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="registered_users", ) - organization = models.ForeignKey( - swapper.get_model_name("openwisp_users", "Organization"), - on_delete=models.CASCADE, - related_name="registered_users", - verbose_name=_("organization"), - help_text=_("Organization associated with this registered user entry."), - ) method = models.CharField( _("registration method"), help_text=_( diff --git a/openwisp_radius/migrations/0043_registereduser_add_uuid.py b/openwisp_radius/migrations/0043_registereduser_add_uuid.py index d280f8ae..6510388f 100644 --- a/openwisp_radius/migrations/0043_registereduser_add_uuid.py +++ b/openwisp_radius/migrations/0043_registereduser_add_uuid.py @@ -26,6 +26,19 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name="phonetoken", + name="organization", + field=models.ForeignKey( + blank=True, + help_text="Organization associated with this phone token.", + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="phone_tokens", + to=swapper.get_model_name("openwisp_users", "Organization"), + verbose_name="organization", + ), + ), migrations.SeparateDatabaseAndState( state_operations=[ migrations.AddField( diff --git a/openwisp_radius/migrations/0044_registered_user_multitenant_data.py b/openwisp_radius/migrations/0044_registered_user_multitenant_data.py index da104a51..88857dcb 100644 --- a/openwisp_radius/migrations/0044_registered_user_multitenant_data.py +++ b/openwisp_radius/migrations/0044_registered_user_multitenant_data.py @@ -1,8 +1,11 @@ +import swapper +from django.conf import settings from django.db import migrations from . import ( migrate_registered_users_multitenant_forward, migrate_registered_users_multitenant_reverse, + populate_phonetoken_organization, ) @@ -20,10 +23,16 @@ def migrate_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", "0043_registereduser_add_uuid"), ] operations = [ + migrations.RunPython( + populate_phonetoken_organization, + migrations.RunPython.noop, + ), 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 index e0710200..f9f2e5b8 100644 --- a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py +++ b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py @@ -1,20 +1,31 @@ +import swapper +from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + swapper.dependency("openwisp_users", "Organization"), ("openwisp_radius", "0044_registered_user_multitenant_data"), ] operations = [ + migrations.AlterField( + model_name="phonetoken", + name="organization", + field=models.ForeignKey( + on_delete=models.deletion.CASCADE, + to=swapper.get_model_name("openwisp_users", "Organization"), + verbose_name="organization", + ), + ), 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", + 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 b0821e01..b8b3d2fc 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -427,3 +427,63 @@ def populate_phonetoken_phone_number(apps, schema_editor): for phone_token in PhoneToken.objects.all(): phone_token.phone_number = phone_token.user.phone_number phone_token.save(update_fields=["phone_number"]) + + +def _get_first_membership_organization_id( + user_id, + OrganizationUser, +): + return ( + OrganizationUser.objects.filter( + user_id=user_id, + ) + .order_by("created", "pk") + .values_list("organization_id", flat=True) + .first() + ) + + +def populate_phonetoken_organization( + apps, + schema_editor, + app_label="openwisp_radius", +): + """Populate PhoneToken.organization_id from the user's first organization. + + For each user that has PhoneToken rows with a null organization_id, + find the user's first OrganizationUser membership (ordered by created, pk) + and set that organization_id on all their PhoneToken records that are + still null. Operates using the provided apps registry (for migrations). + + Args: + apps: Django apps registry passed to migrations functions. + schema_editor: Schema editor passed to migrations functions (unused). + app_label: App label to load the PhoneToken model from. + """ + PhoneToken = apps.get_model(app_label, "PhoneToken") + OrganizationUser = get_swapped_model( + apps, + "openwisp_users", + "OrganizationUser", + ) + user_ids = ( + PhoneToken.objects.filter( + organization_id__isnull=True, + ) + .order_by() + .values_list("user_id", flat=True) + .distinct() + ) + for user_id in user_ids.iterator(chunk_size=BATCH_SIZE): + organization_id = _get_first_membership_organization_id( + user_id, + OrganizationUser, + ) + if organization_id is None: + continue + PhoneToken.objects.filter( + user_id=user_id, + organization_id__isnull=True, + ).update( + organization_id=organization_id, + ) diff --git a/openwisp_radius/saml/views.py b/openwisp_radius/saml/views.py index 58f3c04d..53afddd3 100644 --- a/openwisp_radius/saml/views.py +++ b/openwisp_radius/saml/views.py @@ -90,14 +90,35 @@ def post_login_hook(self, request, user, session_info): registered_user.is_verified = app_settings.SAML_IS_VERIFIED registered_user.full_clean() registered_user.save() - if created and user.email: - # The user is just created, it will not have an email address + if user.email: try: - email_address = EmailAddress( - user=user, email=user.email, primary=True, verified=True + user_has_primary_email = EmailAddress.objects.filter( + user=user, primary=True ) - email_address.full_clean() - email_address.save() + email_address, email_created = EmailAddress.objects.get_or_create( + user=user, + email=user.email, + defaults={ + "verified": True, + "primary": not user_has_primary_email.exists(), + }, + ) + if email_created: + email_address.full_clean() + else: + changed_fields = [] + if not email_address.verified: + email_address.verified = True + changed_fields.append("verified") + if ( + not email_address.primary + and not user_has_primary_email.exists() + ): + email_address.primary = True + changed_fields.append("primary") + if changed_fields: + email_address.full_clean() + email_address.save(update_fields=changed_fields) except ValidationError: logger.exception( f'Failed email validation for "{user}" during' diff --git a/openwisp_radius/social/views.py b/openwisp_radius/social/views.py index f96a6791..ba9f84ed 100644 --- a/openwisp_radius/social/views.py +++ b/openwisp_radius/social/views.py @@ -57,8 +57,8 @@ def authorize(self, request, org, *args, **kwargs): if registered_user.method == "pending_verification": registered_user.method = "social_login" registered_user.is_verified = False - registered_user.full_clean() - registered_user.save() + registered_user.full_clean() + registered_user.save() def get_redirect_url(self, request, organization): """ diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index 3bb3a70f..b927b467 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -6,8 +6,6 @@ from django.contrib.auth.models import Permission from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured -from django.db import connection -from django.test.utils import CaptureQueriesContext from django.urls import reverse from django.utils.translation import gettext_lazy as _ diff --git a/openwisp_radius/tests/test_api/test_phone_verification.py b/openwisp_radius/tests/test_api/test_phone_verification.py index 7fd10451..db76f551 100644 --- a/openwisp_radius/tests/test_api/test_phone_verification.py +++ b/openwisp_radius/tests/test_api/test_phone_verification.py @@ -352,11 +352,15 @@ def test_validate_phone_token_200(self): # generate entropy to ensure correct token is used PhoneToken.objects.create( user=user, + organization=self.default_org, ip=phone_token.ip, phone_number=phone_token.phone_number, ) phone_token = PhoneToken.objects.create( - user=user, ip=phone_token.ip, phone_number=phone_token.phone_number + user=user, + organization=self.default_org, + ip=phone_token.ip, + phone_number=phone_token.phone_number, ) cache_key = f"rt-{phone_token.phone_number}" cache.set(cache_key, "test") @@ -390,6 +394,54 @@ def test_validate_phone_token_400_not_member(self): self.assertIn("non_field_errors", r.data) self.assertIn("is not member", str(r.data["non_field_errors"])) + @capture_any_output() + @mock.patch("openwisp_radius.utils.SmsMessage.send") + def test_validate_phone_token_400_token_from_different_org(self, *args): + org1 = self.default_org + 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 = "+595972157633" + org2_settings.full_clean() + org2_settings.save() + + self._register_user(expect_users=None) + user = User.objects.get(email=self._test_email) + self._create_org_user(user=user, organization=org2) + reg_org2 = RegisteredUser( + user=user, + organization=org2, + method="mobile_phone", + is_verified=False, + ) + reg_org2.full_clean() + reg_org2.save() + user_token = Token.objects.get(user=user) + url = reverse("radius:phone_token_create", args=[org1.slug]) + response = self.client.post(url, HTTP_AUTHORIZATION=f"Bearer {user_token.key}") + self.assertEqual(response.status_code, 201) + phone_token = PhoneToken.objects.filter(user=user, organization=org1).first() + + url = reverse("radius:phone_token_validate", args=[org2.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, 400) + reg_org2.refresh_from_db() + self.assertEqual(reg_org2.is_verified, False) + self.assertEqual( + RegisteredUser.objects.filter( + user=user, organization=org1, is_verified=False + ).count(), + 1, + ) + def test_validate_phone_token_400_invalid(self): self.test_create_phone_token_201() user = User.objects.get(email=self._test_email) @@ -966,6 +1018,7 @@ def test_phone_change_unverifies_only_specific_org(self, *args): phone_token = PhoneToken.objects.create( user=user, + organization=self.default_org, ip="127.0.0.1", phone_number="+393664255801", ) diff --git a/openwisp_radius/tests/test_api/test_rest_token.py b/openwisp_radius/tests/test_api/test_rest_token.py index ae054525..1d2f9e49 100644 --- a/openwisp_radius/tests/test_api/test_rest_token.py +++ b/openwisp_radius/tests/test_api/test_rest_token.py @@ -440,7 +440,10 @@ def test_validate_auth_token_with_inactive_user(self): user.save() user.refresh_from_db() phone_token = PhoneToken( - user=user, ip="127.0.0.1", phone_number="+237675578296" + user=user, + organization=self.default_org, + ip="127.0.0.1", + phone_number="+237675578296", ) phone_token.full_clean() phone_token.save() @@ -509,7 +512,10 @@ def test_multi_org_phone_verification_flow(self, *args): 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" + user=user, + organization=org_a, + ip="127.0.0.1", + phone_number="+393664255801", ) url = reverse("radius:phone_token_validate", args=[org_a.slug]) response = self.client.post( @@ -568,7 +574,10 @@ def test_multi_org_phone_verification_flow(self, *args): 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" + user=user, + organization=org_b, + ip="127.0.0.1", + phone_number="+393664255802", ) url = reverse("radius:phone_token_validate", args=[org_b.slug]) response = self.client.post( diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py index af3c45f6..ca956a7d 100644 --- a/openwisp_radius/tests/test_migrations.py +++ b/openwisp_radius/tests/test_migrations.py @@ -1,24 +1,29 @@ from datetime import timedelta +import swapper from django.apps.registry import apps from django.db import connection from django.utils import timezone from freezegun import freeze_time -from ..migrations import migrate_registered_users_multitenant_reverse +from ..migrations import ( + _get_first_membership_organization_id, + migrate_registered_users_multitenant_reverse, +) from ..utils import load_model from .mixins import BaseTestCase RegisteredUser = load_model("RegisteredUser") +OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") class TestMigrations(BaseTestCase): app_label = "openwisp_radius" - def test_registered_user_organization_column_is_not_nullable(self): - registered_user_model = load_model("RegisteredUser") - table_name = registered_user_model._meta.db_table - column_name = registered_user_model._meta.get_field("organization").column + def _assert_column_not_nullable(self, model_name, field_name): + model = load_model(model_name) + table_name = model._meta.db_table + column_name = model._meta.get_field(field_name).column with connection.cursor() as cursor: columns = connection.introspection.get_table_description( cursor, @@ -37,6 +42,12 @@ def test_registered_user_organization_column_is_not_nullable(self): f"Column '{table_name}.{column_name}' must be NOT NULL at DB level", ) + def test_registered_user_organization_column_is_not_nullable(self): + self._assert_column_not_nullable("RegisteredUser", "organization") + + def test_phone_token_organization_column_is_not_nullable(self): + self._assert_column_not_nullable("PhoneToken", "organization") + def test_multitenant_reverse_keeps_record_with_stronger_method(self): """ Test that a stronger verification method wins when verification @@ -340,3 +351,54 @@ def test_multitenant_reverse_full_cleanup(self): RegisteredUser.objects.filter(user=user2).count(), 1, ) + + +class TestPhoneTokenOrganizationBackfillResolution(BaseTestCase): + app_label = "openwisp_radius" + + def _set_org_user_created(self, org_user, created): + OrganizationUser.objects.filter(pk=org_user.pk).update(created=created) + org_user.refresh_from_db(fields=["created"]) + return org_user + + def test_get_first_membership_returns_earliest_membership(self): + user = self._create_user(username="phone-membership-user") + org1 = self.default_org + org2 = self._create_org( + name="phone-membership-org2", slug="phone-membership-org2" + ) + org1_user = self._create_org_user(user=user, organization=org1) + org2_user = self._create_org_user(user=user, organization=org2) + base_time = timezone.now() + self._set_org_user_created(org1_user, base_time + timedelta(days=1)) + self._set_org_user_created(org2_user, base_time) + organization_id = _get_first_membership_organization_id( + user.pk, + OrganizationUser, + ) + self.assertEqual(organization_id, org2.pk) + + def test_get_first_membership_tie_breaks_by_pk(self): + user = self._create_user(username="phone-membership-tie-user") + org1 = self.default_org + org2 = self._create_org( + name="phone-membership-tie-org2", slug="phone-membership-tie-org2" + ) + org1_user = self._create_org_user(user=user, organization=org1) + org2_user = self._create_org_user(user=user, organization=org2) + base_time = timezone.now() + self._set_membership_created(org1_user, base_time) + self._set_membership_created(org2_user, base_time) + organization_id = _get_first_membership_organization_id( + user.pk, + OrganizationUser, + ) + self.assertEqual(organization_id, org1.pk) + + def test_get_first_membership_returns_none_without_membership(self): + user = self._create_user(username="phone-unresolved-user") + organization_id = _get_first_membership_organization_id( + user.pk, + OrganizationUser, + ) + self.assertEqual(organization_id, None) diff --git a/openwisp_radius/tests/test_saml/test_views.py b/openwisp_radius/tests/test_saml/test_views.py index e482f03a..d35aae32 100644 --- a/openwisp_radius/tests/test_saml/test_views.py +++ b/openwisp_radius/tests/test_saml/test_views.py @@ -3,6 +3,7 @@ from urllib.parse import parse_qs, urlparse import swapper +from allauth.account.models import EmailAddress from django.conf import settings from django.contrib.auth import SESSION_KEY, get_user_model from django.core import mail @@ -224,6 +225,135 @@ def test_user_registered_with_non_saml_method(self): user.refresh_from_db() self.assertEqual(user.username, "org_user@example.com") + @capture_any_output() + def test_saml_login_marks_existing_email_verified(self): + org = Organization.objects.get(slug="default") + user = self._create_user(username="test-user", email="org_user@example.com") + user.emailaddress_set.all().delete() + email_address = EmailAddress.objects.create( + user=user, + email="org_user@example.com", + primary=True, + verified=False, + ) + registered_user = RegisteredUser.objects.create( + user=user, + organization=org, + method="pending_verification", + is_verified=False, + ) + relay_state = self._get_relay_state( + redirect_url="https://captive-portal.example.com", org_slug="default" + ) + saml_response, relay_state = self._get_saml_response_for_acs_view(relay_state) + response = self.client.post( + reverse("radius:saml2_acs"), + { + "SAMLResponse": self.b64_for_post(saml_response), + "RelayState": relay_state, + }, + ) + self.assertEqual(response.status_code, 302) + email_address.refresh_from_db() + registered_user.refresh_from_db() + self.assertTrue(email_address.verified) + self.assertTrue(email_address.primary) + self.assertEqual(EmailAddress.objects.filter(user=user).count(), 1) + self.assertEqual(registered_user.method, "saml") + self.assertEqual(registered_user.is_verified, app_settings.SAML_IS_VERIFIED) + self.assertEqual( + RegisteredUser.objects.filter(user=user, organization=org).count(), 1 + ) + + @capture_any_output() + def test_saml_login_existing_email_already_verified(self): + org = Organization.objects.get(slug="default") + user = self._create_user(username="test-user", email="org_user@example.com") + user.emailaddress_set.all().delete() + email_address = EmailAddress.objects.create( + user=user, + email="org_user@example.com", + primary=True, + verified=True, + ) + registered_user = RegisteredUser.objects.create( + user=user, + organization=org, + method="pending_verification", + is_verified=False, + ) + relay_state = self._get_relay_state( + redirect_url="https://captive-portal.example.com", org_slug="default" + ) + saml_response, relay_state = self._get_saml_response_for_acs_view(relay_state) + response = self.client.post( + reverse("radius:saml2_acs"), + { + "SAMLResponse": self.b64_for_post(saml_response), + "RelayState": relay_state, + }, + ) + self.assertEqual(response.status_code, 302) + email_address.refresh_from_db() + registered_user.refresh_from_db() + self.assertEqual(email_address.verified, True) + self.assertEqual(email_address.primary, True) + self.assertEqual(EmailAddress.objects.filter(user=user).count(), 1) + self.assertEqual(registered_user.method, "saml") + self.assertEqual(registered_user.is_verified, app_settings.SAML_IS_VERIFIED) + self.assertEqual( + RegisteredUser.objects.filter(user=user, organization=org).count(), 1 + ) + + @override_settings(SAML_DJANGO_USER_MAIN_ATTRIBUTE="username") + @capture_any_output() + def test_saml_login_preserves_existing_primary_email_different_uid(self): + org = Organization.objects.get(slug="default") + user = self._create_user( + username="saml-user@example.com", + email="existing-primary@example.com", + ) + user.emailaddress_set.all().delete() + existing_primary = EmailAddress.objects.create( + user=user, + email="existing-primary@example.com", + primary=True, + verified=True, + ) + registered_user = RegisteredUser.objects.create( + user=user, + organization=org, + method="pending_verification", + is_verified=False, + ) + relay_state = self._get_relay_state( + redirect_url="https://captive-portal.example.com", org_slug="default" + ) + saml_response, relay_state = self._get_saml_response_for_acs_view( + relay_state, uid="saml-user@example.com" + ) + response = self.client.post( + reverse("radius:saml2_acs"), + { + "SAMLResponse": self.b64_for_post(saml_response), + "RelayState": relay_state, + }, + ) + self.assertEqual(response.status_code, 302) + existing_primary.refresh_from_db() + self.assertEqual(existing_primary.primary, True) + self.assertEqual(existing_primary.verified, True) + new_email = EmailAddress.objects.get(user=user, email="saml-user@example.com") + self.assertEqual(new_email.primary, False) + self.assertEqual(new_email.verified, True) + self.assertEqual(EmailAddress.objects.filter(user=user).count(), 2) + registered_user.refresh_from_db() + self.assertEqual(registered_user.method, "saml") + self.assertEqual(registered_user.is_verified, app_settings.SAML_IS_VERIFIED) + self.assertEqual( + RegisteredUser.objects.filter(user=user, organization=org).count(), 1 + ) + @override_settings(SAML_ALLOWED_HOSTS=["captive-portal.example.com"]) class TestAdditionInfoView(TestSamlMixin, TestCase): @@ -324,12 +454,15 @@ def test_saml_login_disabled(self): org.radius_settings.save() redirect_url = "https://captive-portal.example.com" with self.subTest("SAML authentication is disabled site-wide"): - with patch( - "openwisp_radius.settings.SAML_REGISTRATION_ENABLED", False - ), patch.object( - OrganizationRadiusSettings._meta.get_field("saml_registration_enabled"), - "fallback", - False, + with ( + patch("openwisp_radius.settings.SAML_REGISTRATION_ENABLED", False), + patch.object( + OrganizationRadiusSettings._meta.get_field( + "saml_registration_enabled" + ), + "fallback", + False, + ), ): response = self.client.get( self.login_url, diff --git a/openwisp_radius/tests/test_token.py b/openwisp_radius/tests/test_token.py index 6e89a688..57366fb0 100644 --- a/openwisp_radius/tests/test_token.py +++ b/openwisp_radius/tests/test_token.py @@ -41,7 +41,12 @@ def setUp(self): radius_settings.save() def _create_token( - self, user=None, ip="127.0.0.1", phone_number="+393664351808", created=None + self, + user=None, + organization=None, + ip="127.0.0.1", + phone_number="+393664351808", + created=None, ): if not user: opts = { @@ -53,7 +58,14 @@ def _create_token( } user = self._create_user(**opts) self._create_org_user(**{"user": user}) - token = PhoneToken(user=user, ip=ip, phone_number=phone_number) + if organization is None: + organization = self.default_org + token = PhoneToken( + user=user, + organization=organization, + ip=ip, + phone_number=phone_number, + ) if created: token.created = created token.modified = created 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 82b97682..97b92bd3 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,32 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name="phonetoken", + name="organization", + field=models.ForeignKey( + blank=True, + help_text="Organization associated with this phone token.", + null=True, + on_delete=models.deletion.CASCADE, + related_name="phone_tokens", + to=swapper.get_model_name("openwisp_users", "Organization"), + verbose_name="organization", + ), + ), + migrations.RunPython( + populate_sample_phonetoken_organization, + migrations.RunPython.noop, + ), + migrations.AlterField( + model_name="phonetoken", + name="organization", + field=models.ForeignKey( + on_delete=models.deletion.CASCADE, + to=swapper.get_model_name("openwisp_users", "Organization"), + verbose_name="organization", + ), + ), migrations.SeparateDatabaseAndState( database_operations=[ migrations.CreateModel( From 61e05baa2bf543d76fa8efd522f69cb3a0546be0 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 14 May 2026 00:45:21 +0530 Subject: [PATCH 33/45] [fix] Fixed QA issues --- ...registered_user_multitenant_constraints.py | 22 +++++++++++++ .../0032_registered_user_multitenant.py | 31 ++++++++++++++++--- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py index f9f2e5b8..69dd8603 100644 --- a/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py +++ b/openwisp_radius/migrations/0045_registered_user_multitenant_constraints.py @@ -11,6 +11,14 @@ class Migration(migrations.Migration): ] operations = [ + migrations.RemoveIndex( + model_name="phonetoken", + name="openwisp_ra_user_id_9fe207_idx", + ), + migrations.RemoveIndex( + model_name="phonetoken", + name="openwisp_ra_user_id_d4dd52_idx", + ), migrations.AlterField( model_name="phonetoken", name="organization", @@ -20,6 +28,20 @@ class Migration(migrations.Migration): verbose_name="organization", ), ), + migrations.AddIndex( + model_name="phonetoken", + index=models.Index( + fields=["user", "created"], + name="openwisp_ra_user_id_9fe207_idx", + ), + ), + migrations.AddIndex( + model_name="phonetoken", + index=models.Index( + fields=["user", "created", "ip"], + name="openwisp_ra_user_id_d4dd52_idx", + ), + ), migrations.AlterField( model_name="registereduser", name="organization", 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 97b92bd3..2fb4fd18 100644 --- a/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py +++ b/tests/openwisp2/sample_radius/migrations/0032_registered_user_multitenant.py @@ -13,6 +13,7 @@ copy_registered_users_ctcr_reverse, migrate_registered_users_multitenant_forward, migrate_registered_users_multitenant_reverse, + populate_phonetoken_organization, ) @@ -52,6 +53,10 @@ def migrate_registered_users_reverse(apps, schema_editor): ) +def populate_sample_phonetoken_organization(apps, schema_editor): + populate_phonetoken_organization(apps, schema_editor, app_label="sample_radius") + + class Migration(migrations.Migration): dependencies = [ @@ -78,6 +83,14 @@ class Migration(migrations.Migration): populate_sample_phonetoken_organization, migrations.RunPython.noop, ), + migrations.RemoveIndex( + model_name="phonetoken", + name="sample_radi_user_id_b748c7_idx", + ), + migrations.RemoveIndex( + model_name="phonetoken", + name="sample_radi_user_id_044fca_idx", + ), migrations.AlterField( model_name="phonetoken", name="organization", @@ -87,6 +100,20 @@ class Migration(migrations.Migration): verbose_name="organization", ), ), + migrations.AddIndex( + model_name="phonetoken", + index=models.Index( + fields=["user", "created"], + name="sample_radi_user_id_b748c7_idx", + ), + ), + migrations.AddIndex( + model_name="phonetoken", + index=models.Index( + fields=["user", "created", "ip"], + name="sample_radi_user_id_044fca_idx", + ), + ), migrations.SeparateDatabaseAndState( database_operations=[ migrations.CreateModel( @@ -238,10 +265,6 @@ class Migration(migrations.Migration): model_name="registereduser", name="organization", field=models.ForeignKey( - help_text=( - "Organization associated with this registered user entry." - ), - related_name="registered_users", on_delete=django.db.models.deletion.CASCADE, to=swapper.get_model_name("openwisp_users", "Organization"), verbose_name="organization", From 2b741c4c3a09e0dd07916b070c7b05004d8f0ee4 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 14 May 2026 01:14:14 +0530 Subject: [PATCH 34/45] [fix] Fixed migration tests --- openwisp_radius/tests/test_migrations.py | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py index ca956a7d..725773c2 100644 --- a/openwisp_radius/tests/test_migrations.py +++ b/openwisp_radius/tests/test_migrations.py @@ -353,7 +353,7 @@ def test_multitenant_reverse_full_cleanup(self): ) -class TestPhoneTokenOrganizationBackfillResolution(BaseTestCase): +class TestPhoneTokenOrganizationPopulateResolution(BaseTestCase): app_label = "openwisp_radius" def _set_org_user_created(self, org_user, created): @@ -367,8 +367,8 @@ def test_get_first_membership_returns_earliest_membership(self): org2 = self._create_org( name="phone-membership-org2", slug="phone-membership-org2" ) - org1_user = self._create_org_user(user=user, organization=org1) - org2_user = self._create_org_user(user=user, organization=org2) + org1_user = OrganizationUser.objects.create(user=user, organization=org1) + org2_user = OrganizationUser.objects.create(user=user, organization=org2) base_time = timezone.now() self._set_org_user_created(org1_user, base_time + timedelta(days=1)) self._set_org_user_created(org2_user, base_time) @@ -378,23 +378,6 @@ def test_get_first_membership_returns_earliest_membership(self): ) self.assertEqual(organization_id, org2.pk) - def test_get_first_membership_tie_breaks_by_pk(self): - user = self._create_user(username="phone-membership-tie-user") - org1 = self.default_org - org2 = self._create_org( - name="phone-membership-tie-org2", slug="phone-membership-tie-org2" - ) - org1_user = self._create_org_user(user=user, organization=org1) - org2_user = self._create_org_user(user=user, organization=org2) - base_time = timezone.now() - self._set_membership_created(org1_user, base_time) - self._set_membership_created(org2_user, base_time) - organization_id = _get_first_membership_organization_id( - user.pk, - OrganizationUser, - ) - self.assertEqual(organization_id, org1.pk) - def test_get_first_membership_returns_none_without_membership(self): user = self._create_user(username="phone-unresolved-user") organization_id = _get_first_membership_organization_id( From f63a5225ad0259fdff8a39b98483849e88a7cf77 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 14 May 2026 20:36:09 +0530 Subject: [PATCH 35/45] [fix] Fixed security issues --- docs/user/management_commands.rst | 4 + openwisp_radius/admin.py | 43 ++++--- openwisp_radius/api/views.py | 9 ++ openwisp_radius/base/admin_filters.py | 14 ++- openwisp_radius/base/models.py | 12 ++ openwisp_radius/migrations/__init__.py | 8 +- openwisp_radius/tests/test_admin.py | 112 ++++++++++++++++++ openwisp_radius/tests/test_api/test_api.py | 38 +++++- .../tests/test_api/test_phone_verification.py | 58 +++++++++ openwisp_radius/tests/test_commands.py | 30 +++++ openwisp_radius/tests/test_models.py | 19 ++- 11 files changed, 318 insertions(+), 29 deletions(-) diff --git a/docs/user/management_commands.rst b/docs/user/management_commands.rst index 575c3511..68686620 100644 --- a/docs/user/management_commands.rst +++ b/docs/user/management_commands.rst @@ -129,6 +129,10 @@ Following is an example: ./manage.py delete_unverified_users --older-than-days 1 --exclude-methods mobile_phone,email +If a user has multiple ``RegisteredUser`` rows across organizations, the +command keeps that user when **any** related row uses one of the excluded +methods. + ``upgrade_from_django_freeradius`` ---------------------------------- diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index af55f708..3aa42c46 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -576,35 +576,44 @@ def has_delete_permission(self, request, obj=None): def get_queryset(self, request): + self._verification_request = request queryset = user_admin_get_queryset(self, request) + registered_users = RegisteredUser.objects.only( + "user_id", "organization_id", "is_verified" + ) + if not request.user.is_superuser: + registered_users = registered_users.filter( + organization__in=request.user.organizations_managed + ) return queryset.prefetch_related( Prefetch( "registered_users", - queryset=RegisteredUser.objects.only("user_id", "is_verified"), + queryset=registered_users, to_attr="prefetched_registered_users", ) ) def get_is_verified(self, obj): - try: - prefetched_registered_users = getattr(obj, "prefetched_registered_users", None) - if prefetched_registered_users is not None: - is_verifieds = [ - reg_user.is_verified for reg_user in prefetched_registered_users - ] - else: - is_verifieds = list( - obj.registered_users.values_list("is_verified", flat=True) + prefetched_registered_users = getattr(obj, "prefetched_registered_users", None) + if prefetched_registered_users is not None: + is_verifieds = [ + reg_user.is_verified for reg_user in prefetched_registered_users + ] + else: + registered_users = obj.registered_users.all() + request = getattr(self, "_verification_request", None) + if request is not None and not request.user.is_superuser: + registered_users = registered_users.filter( + organization__in=request.user.organizations_managed ) - if not is_verifieds: - value = "unknown" - elif any(is_verifieds): - value = "yes" - else: - value = "no" - except Exception: + is_verifieds = list(registered_users.values_list("is_verified", flat=True)) + if not is_verifieds: value = "unknown" + elif any(is_verifieds): + value = "yes" + else: + value = "no" icon_url = static(f"admin/img/icon-{value}.svg") return mark_safe(f'{value}') diff --git a/openwisp_radius/api/views.py b/openwisp_radius/api/views.py index 581d83d3..0586aa08 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -770,6 +770,14 @@ def post(self, request, *args, **kwargs): if not is_valid: return self._error_response(_("Invalid code.")) else: + phone_number_changed = user.phone_number and str(user.phone_number) != str( + phone_token.phone_number + ) + if phone_number_changed: + RegisteredUser.objects.filter( + user=user, + method="mobile_phone", + ).exclude(organization=self.organization,).update(is_verified=False) reg_user, __ = RegisteredUser.get_or_create_for_user_and_org( user=user, organization=self.organization, @@ -862,6 +870,7 @@ class UpdateRegisteredUserMethodView(DispatchOrgMixin, GenericAPIView): ) def post(self, request, slug): user = request.user + self.validate_membership(user) reg_user = get_object_or_404( RegisteredUser, user_id=user.pk, diff --git a/openwisp_radius/base/admin_filters.py b/openwisp_radius/base/admin_filters.py index d8c1f7d4..6eea1255 100644 --- a/openwisp_radius/base/admin_filters.py +++ b/openwisp_radius/base/admin_filters.py @@ -1,4 +1,5 @@ from django.contrib.admin import SimpleListFilter +from django.db.models import Q from django.utils.translation import gettext_lazy as _ @@ -14,10 +15,13 @@ def lookups(self, request, model_admin): ) def queryset(self, request, queryset): + where = Q() + if not request.user.is_superuser: + where = Q( + registered_users__organization__in=request.user.organizations_managed + ) if self.value() == "unknown": - return queryset.filter(registered_users__isnull=True) + where &= Q(registered_users__isnull=True) elif self.value(): - return queryset.filter( - registered_users__is_verified=self.value() == "true" - ).distinct() - return queryset + where &= Q(registered_users__is_verified=self.value() == "true") + return queryset.filter(where).distinct() diff --git a/openwisp_radius/base/models.py b/openwisp_radius/base/models.py index 9def851e..1bacd630 100644 --- a/openwisp_radius/base/models.py +++ b/openwisp_radius/base/models.py @@ -1683,6 +1683,18 @@ def get_or_create_for_user_and_org(cls, user, organization, defaults=None): @classmethod def get_for_user_and_org(cls, user, organization): + prefetched_registered_users = getattr(user, "prefetched_registered_users", None) + if prefetched_registered_users is None: + prefetched_registered_users = getattr( + user, + "_prefetched_objects_cache", + {}, + ).get("registered_users") + if prefetched_registered_users is not None: + for registered_user in prefetched_registered_users: + if registered_user.organization_id == organization.pk: + return registered_user + return None try: return cls.objects.get(user=user, organization=organization) except cls.DoesNotExist: diff --git a/openwisp_radius/migrations/__init__.py b/openwisp_radius/migrations/__init__.py index b8b3d2fc..08810ba9 100644 --- a/openwisp_radius/migrations/__init__.py +++ b/openwisp_radius/migrations/__init__.py @@ -453,7 +453,12 @@ def populate_phonetoken_organization( For each user that has PhoneToken rows with a null organization_id, find the user's first OrganizationUser membership (ordered by created, pk) and set that organization_id on all their PhoneToken records that are - still null. Operates using the provided apps registry (for migrations). + still null. + + Any rows that cannot be resolved to an organization are + discarded before the later NOT NULL migration step. + + Operates using the provided apps registry (for migrations). Args: apps: Django apps registry passed to migrations functions. @@ -487,3 +492,4 @@ def populate_phonetoken_organization( ).update( organization_id=organization_id, ) + PhoneToken.objects.filter(organization_id__isnull=True).delete() diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index b927b467..bbe7bf94 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -2,10 +2,12 @@ import lxml.html as lxml_html import swapper +from django.contrib import admin from django.contrib.auth import get_user_model from django.contrib.auth.models import Permission from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured +from django.test import RequestFactory from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -1577,6 +1579,116 @@ def get_expected_html(value): self.assertNotContains(response, get_expected_html("no")) self.assertContains(response, get_expected_html("unknown")) + def test_get_is_verified_scoped_to_managed_organizations(self): + org1 = self._create_org(name="org-1", slug="org-1") + org2 = self._create_org(name="org-2", slug="org-2") + manager = self._create_administrator([org1]) + scoped_user = self._create_user( + username="scoped-user", + email="scoped-user@test.com", + ) + other_org_user = self._create_user( + username="other-org-user", + email="other-org-user@test.com", + ) + self._create_org_user(user=scoped_user, organization=org1) + self._create_org_user(user=other_org_user, organization=org1) + RegisteredUser.objects.create( + user=scoped_user, + organization=org1, + method="mobile_phone", + is_verified=False, + ) + RegisteredUser.objects.create( + user=scoped_user, + organization=org2, + method="mobile_phone", + is_verified=True, + ) + RegisteredUser.objects.create( + user=other_org_user, + organization=org2, + method="mobile_phone", + is_verified=True, + ) + request = RequestFactory().get( + reverse(f"admin:{User._meta.app_label}_user_changelist") + ) + request.user = manager + user_admin = admin.site._registry[User] + queryset = user_admin.get_queryset(request) + scoped_user = queryset.get(pk=scoped_user.pk) + other_org_user = queryset.get(pk=other_org_user.pk) + # The scoped user should show as unverified since the user admin + # should only consider the registration record from the managed + # organization (org1), while the other org user should show as + # unknown since their registration record in the managed + # organization is missing + self.assertIn("icon-no.svg", user_admin.get_is_verified(scoped_user)) + self.assertIn("icon-unknown.svg", user_admin.get_is_verified(other_org_user)) + + def test_registered_user_filter_scoped_to_managed_organizations(self): + org1 = self._create_org(name="org-1", slug="org-1") + org2 = self._create_org(name="org-2", slug="org-2") + manager = self._create_administrator([org1]) + org1_verified = self._create_user( + username="org1-verified", + email="org1-verified@test.com", + ) + common_user_unverified = self._create_user( + username="common-user-unverified", + email="common-user-unverified@test.com", + ) + org2_only = self._create_user( + username="org2-only", + email="org2-only@test.com", + ) + self._create_org_user(user=org1_verified, organization=org1) + self._create_org_user(user=common_user_unverified, organization=org1) + self._create_org_user(user=org2_only, organization=org1) + RegisteredUser.objects.create( + user=org1_verified, + organization=org1, + method="mobile_phone", + is_verified=True, + ) + RegisteredUser.objects.create( + user=common_user_unverified, + organization=org1, + method="mobile_phone", + is_verified=False, + ) + RegisteredUser.objects.create( + user=common_user_unverified, + organization=org2, + method="mobile_phone", + is_verified=True, + ) + RegisteredUser.objects.create( + user=org2_only, + organization=org2, + method="mobile_phone", + is_verified=True, + ) + self.client.force_login(manager) + app_label = User._meta.app_label + url = reverse(f"admin:{app_label}_user_changelist") + + response = self.client.get(url, {"is_verified": "true"}) + self.assertContains(response, org1_verified.username) + self.assertNotContains(response, common_user_unverified.username) + self.assertNotContains(response, org2_only.username) + + response = self.client.get(url, {"is_verified": "false"}) + self.assertContains(response, common_user_unverified.username) + self.assertNotContains(response, org1_verified.username) + self.assertNotContains(response, org2_only.username) + + response = self.client.get(url, {"is_verified": "unknown"}) + self.assertNotContains(response, org2_only.username) + self.assertNotContains(response, org1_verified.username) + self.assertNotContains(response, common_user_unverified.username) + def test_admin_menu_groups(self): # Test menu group (openwisp-utils menu group) for RadiusAccounting, RadiusBatch, # RadiusCheck, RadiusGroup, Nas, RadiusPostAuth, RadiusToken, and RadiusReply diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 2b1d8b72..516b62a5 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -1698,7 +1698,7 @@ def test_update_registered_user_method_validation_errors(self): 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"): + with self.subTest("non member without registered user"): user = self._create_user(username="noreguser", password="tester") user_token = Token.objects.create(user=user) url = self._get_update_method_url() @@ -1707,9 +1707,11 @@ def test_update_registered_user_method_404_cases(self): {"method": "mobile_phone"}, HTTP_AUTHORIZATION=f"Bearer {user_token.key}", ) - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 400) + self.assertIn("non_field_errors", response.data) + self.assertIn("is not member", str(response.data["non_field_errors"])) - with self.subTest("only_owner_can_update"): + with self.subTest("non member cannot update other users record"): user, org2, user_token = self._create_pending_verification_user( username_suffix="_owner" ) @@ -1723,7 +1725,9 @@ def test_update_registered_user_method_404_cases(self): {"method": "mobile_phone"}, HTTP_AUTHORIZATION=f"Bearer {other_user_token.key}", ) - self.assertEqual(response.status_code, 404) + self.assertEqual(response.status_code, 400) + self.assertIn("non_field_errors", response.data) + self.assertIn("is not member", str(response.data["non_field_errors"])) with self.subTest("invalid_org"): user, _, user_token = self._create_pending_verification_user( @@ -1740,6 +1744,32 @@ def test_update_registered_user_method_404_cases(self): ) self.assertEqual(response.status_code, 404) + def test_update_registered_user_method_rejects_non_member_with_registered_user( + self, + ): + user = self._create_user( + username="nonmember-update", + password="tester", + email="nonmember-update@test.com", + ) + org = self._create_org(name="org-update", slug="org-update") + RegisteredUser.objects.create( + user=user, + organization=org, + method="pending_verification", + is_verified=False, + ) + user_token = Token.objects.create(user=user) + url = self._get_update_method_url(org) + response = self.client.post( + url, + {"method": "mobile_phone"}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 400) + self.assertIn("non_field_errors", response.data) + self.assertIn("is not member", str(response.data["non_field_errors"])) + def test_update_registered_user_method_requires_authentication(self): url = self._get_update_method_url() response = self.client.post(url, {"method": "mobile_phone"}) diff --git a/openwisp_radius/tests/test_api/test_phone_verification.py b/openwisp_radius/tests/test_api/test_phone_verification.py index db76f551..c57d31e7 100644 --- a/openwisp_radius/tests/test_api/test_phone_verification.py +++ b/openwisp_radius/tests/test_api/test_phone_verification.py @@ -1047,6 +1047,64 @@ def test_phone_change_unverifies_only_specific_org(self, *args): reg_org1.refresh_from_db() self.assertEqual(reg_org1.is_verified, False) + @capture_any_output() + @mock.patch("openwisp_radius.utils.SmsMessage.send") + def test_phone_change_requires_reverification_in_other_mobile_phone_orgs( + self, *args + ): + # User registers to both org1 and org2 with phone_number + org1 = self.default_org + org2 = self._create_org(name="org2", slug="org2") + self._register_user(expect_users=None) + user = User.objects.get(email=self._test_email) + self._create_org_user(user=user, organization=org2) + user.phone_number = "+393664255801" + user.save(update_fields=["phone_number"]) + user_token = Token.objects.get(user=user) + reg_org1 = user.registered_users.get(organization=org1) + reg_org1.method = "mobile_phone" + reg_org1.is_verified = True + reg_org1.save(update_fields=["method", "is_verified"]) + reg_org2 = RegisteredUser.objects.create( + user=user, + organization=org2, + method="mobile_phone", + is_verified=True, + ) + # User changes phone number in org1, which should unverify user in org1 and org2 + # since both orgs have same method and phone number + new_phone_number = "+595972157444" + url = reverse("radius:phone_number_change", args=[org1.slug]) + response = self.client.post( + url, + json.dumps({"phone_number": new_phone_number}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + # Validate phone token for org1 + phone_token = ( + PhoneToken.objects.filter(user=user, organization=org1) + .order_by("-created") + .first() + ) + url = reverse("radius:phone_token_validate", args=[org1.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) + # User should be verified in org1 but not in org2 since phone number + # has changed and both orgs have same method and phone number + user.refresh_from_db() + reg_org1.refresh_from_db() + reg_org2.refresh_from_db() + self.assertEqual(user.phone_number, new_phone_number) + self.assertEqual(reg_org1.is_verified, True) + self.assertEqual(reg_org2.is_verified, False) + class TestIsSmsVerificationEnabled(ApiTokenMixin, BaseTestCase): def setUp(self): diff --git a/openwisp_radius/tests/test_commands.py b/openwisp_radius/tests/test_commands.py index c6b6c205..7aa30e47 100644 --- a/openwisp_radius/tests/test_commands.py +++ b/openwisp_radius/tests/test_commands.py @@ -405,6 +405,36 @@ def _create_old_users(): self.assertEqual(User.objects.count(), 1) self.assertEqual(User.objects.filter(pk=user.pk).exists(), True) + with self.subTest( + "Exclude methods keep users when any organization uses an excluded method" + ): + _create_old_users() + org2 = self._create_org(name="exclude org", slug="exclude-org") + user = self._create_user( + username="exclude_any_match", + email="exclude_any_match@test.com", + date_joined=now() - timedelta(days=3), + ) + RegisteredUser.objects.create( + user=user, + organization=self.default_org, + method="email", + is_verified=False, + ) + RegisteredUser.objects.create( + user=user, + organization=org2, + method="mobile_phone", + is_verified=False, + ) + self.assertEqual(User.objects.count(), 4) + call_command( + "delete_unverified_users", + older_than_days=2, + exclude_methods="mobile_phone", + ) + self.assertEqual(User.objects.filter(pk=user.pk).exists(), True) + @capture_any_output() @patch.object( app_settings, diff --git a/openwisp_radius/tests/test_models.py b/openwisp_radius/tests/test_models.py index d784b643..3388b151 100644 --- a/openwisp_radius/tests/test_models.py +++ b/openwisp_radius/tests/test_models.py @@ -1227,18 +1227,33 @@ def test_get_for_user_and_org(self): with self.subTest("returns None when no records exist"): result = RegisteredUser.get_for_user_and_org(user, org1) - self.assertIsNone(result) + self.assertEqual(result, None) with self.subTest("returns only the requested organization record"): org2_ru = RegisteredUser.objects.create( user=user, organization=org2, is_verified=True ) result = RegisteredUser.get_for_user_and_org(user, org1) - self.assertIsNone(result) + self.assertEqual(result, None) result = RegisteredUser.get_for_user_and_org(user, org2) self.assertEqual(result, org2_ru) self.assertEqual(result.is_verified, True) + with self.subTest("uses prefetched registered_users without extra queries"): + org1_ru = RegisteredUser.objects.create( + user=user, + organization=org1, + is_verified=False, + ) + prefetched_user = ( + get_user_model() + .objects.prefetch_related("registered_users") + .get(pk=user.pk) + ) + with self.assertNumQueries(0): + result = RegisteredUser.get_for_user_and_org(prefetched_user, org1) + self.assertEqual(result, org1_ru) + 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") From 870ad8f5fcda02839513c39418121b08c0bc02d4 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 14 May 2026 23:44:10 +0530 Subject: [PATCH 36/45] [fix] Fixed bugs --- openwisp_radius/admin.py | 9 +---- openwisp_radius/api/utils.py | 3 +- openwisp_radius/api/views.py | 17 +++++++-- openwisp_radius/base/admin_filters.py | 2 + openwisp_radius/tests/test_admin.py | 14 +++++++ .../tests/test_api/test_phone_verification.py | 38 +++++++++++++++++++ 6 files changed, 70 insertions(+), 13 deletions(-) diff --git a/openwisp_radius/admin.py b/openwisp_radius/admin.py index 3aa42c46..e505d7dd 100644 --- a/openwisp_radius/admin.py +++ b/openwisp_radius/admin.py @@ -576,7 +576,6 @@ def has_delete_permission(self, request, obj=None): def get_queryset(self, request): - self._verification_request = request queryset = user_admin_get_queryset(self, request) registered_users = RegisteredUser.objects.only( "user_id", "organization_id", "is_verified" @@ -601,13 +600,7 @@ def get_is_verified(self, obj): reg_user.is_verified for reg_user in prefetched_registered_users ] else: - registered_users = obj.registered_users.all() - request = getattr(self, "_verification_request", None) - if request is not None and not request.user.is_superuser: - registered_users = registered_users.filter( - organization__in=request.user.organizations_managed - ) - is_verifieds = list(registered_users.values_list("is_verified", flat=True)) + is_verifieds = [] if not is_verifieds: value = "unknown" elif any(is_verifieds): diff --git a/openwisp_radius/api/utils.py b/openwisp_radius/api/utils.py index d2b5e1f8..0e81d63a 100644 --- a/openwisp_radius/api/utils.py +++ b/openwisp_radius/api/utils.py @@ -34,7 +34,8 @@ def _needs_identity_verification(self, organization_filter_kwargs={}, org=None): def is_identity_verified_strong(self, user, organization): reg_user = None # We use all() to utilize the prefetch cache, otherwise - # it would cause an additional query to fetch the registered user + # it would cause an additional query to fetch the organization-specific + # registration record 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 0586aa08..b87505cd 100644 --- a/openwisp_radius/api/views.py +++ b/openwisp_radius/api/views.py @@ -770,10 +770,13 @@ def post(self, request, *args, **kwargs): if not is_valid: return self._error_response(_("Invalid code.")) else: - phone_number_changed = user.phone_number and str(user.phone_number) != str( + old_phone_number = str(user.phone_number) if user.phone_number else None + phone_number_changed = old_phone_number and old_phone_number != str( phone_token.phone_number ) if phone_number_changed: + # The shipped registration methods only tie identity verification + # to the stored phone number for mobile_phone entries. RegisteredUser.objects.filter( user=user, method="mobile_phone", @@ -796,8 +799,13 @@ def post(self, request, *args, **kwargs): user.phone_number = phone_token.phone_number user.save() reg_user.save() - # delete any radius token cache key if present - cache.delete(f"rt-{phone_token.phone_number}") + # Delete any cached radius token for either the previous or current + # phone number so callers cannot keep using stale cached entries. + cache_keys = {f"rt-{phone_token.phone_number}"} + if old_phone_number: + cache_keys.add(f"rt-{old_phone_number}") + for cache_key in cache_keys: + cache.delete(cache_key) return Response(None, status=200) @@ -853,7 +861,8 @@ class UpdateRegisteredUserMethodView(DispatchOrgMixin, GenericAPIView): @swagger_auto_schema( operation_description=(""" **Requires the user auth token (Bearer Token).** - Allows users to update their registered user method for an organization. + Allows users to update their organization-specific registration + method. The method can only be updated when it is currently set to 'pending_verification'. Once updated, it cannot be changed again via this endpoint. diff --git a/openwisp_radius/base/admin_filters.py b/openwisp_radius/base/admin_filters.py index 6eea1255..79211727 100644 --- a/openwisp_radius/base/admin_filters.py +++ b/openwisp_radius/base/admin_filters.py @@ -15,6 +15,8 @@ def lookups(self, request, model_admin): ) def queryset(self, request, queryset): + if self.value() is None: + return queryset where = Q() if not request.user.is_superuser: where = Q( diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index bbe7bf94..b9b2fb49 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -1689,6 +1689,20 @@ def test_registered_user_filter_scoped_to_managed_organizations(self): self.assertNotContains(response, org1_verified.username) self.assertNotContains(response, common_user_unverified.username) + def test_registered_user_filter_does_not_limit_default_changelist(self): + org = self._create_org(name="org-filter-default", slug="org-filter-default") + manager = self._create_administrator([org]) + user = self._create_user( + username="no-registered-user", + email="no-registered-user@test.com", + ) + self._create_org_user(user=user, organization=org) + self.client.force_login(manager) + app_label = User._meta.app_label + url = reverse(f"admin:{app_label}_user_changelist") + response = self.client.get(url) + self.assertContains(response, user.username) + def test_admin_menu_groups(self): # Test menu group (openwisp-utils menu group) for RadiusAccounting, RadiusBatch, # RadiusCheck, RadiusGroup, Nas, RadiusPostAuth, RadiusToken, and RadiusReply diff --git a/openwisp_radius/tests/test_api/test_phone_verification.py b/openwisp_radius/tests/test_api/test_phone_verification.py index c57d31e7..425c630d 100644 --- a/openwisp_radius/tests/test_api/test_phone_verification.py +++ b/openwisp_radius/tests/test_api/test_phone_verification.py @@ -383,6 +383,44 @@ def test_validate_phone_token_200(self): self.assertEqual(reg_user.method, "mobile_phone") self.assertIsNone(cache.get(cache_key)) + @capture_any_output() + @mock.patch("openwisp_radius.utils.SmsMessage.send") + def test_validate_phone_token_200_clears_old_and_new_radius_token_cache_keys( + self, *args + ): + self._register_user(expect_users=None) + user = User.objects.get(email=self._test_email) + user_token = Token.objects.get(user=user) + old_phone_number = str(user.phone_number) + new_phone_number = "+595972157445" + url = reverse("radius:phone_number_change", args=[self.default_org.slug]) + response = self.client.post( + url, + json.dumps({"phone_number": new_phone_number}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + + phone_token = PhoneToken.objects.filter( + user=user, organization=self.default_org + ).last() + old_cache_key = f"rt-{old_phone_number}" + new_cache_key = f"rt-{phone_token.phone_number}" + cache.set(old_cache_key, "old-test") + cache.set(new_cache_key, "new-test") + + 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) + self.assertEqual(cache.get(old_cache_key), None) + self.assertEqual(cache.get(new_cache_key), None) + @capture_any_output() def test_validate_phone_token_400_not_member(self): self.test_create_phone_token_201() From 9d382b48f1ca0b818be69c6ace9679d303c1b6f6 Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Sat, 16 May 2026 19:13:00 -0300 Subject: [PATCH 37/45] [chores] Made sure migration tests are extensible + minor improvements --- openwisp_radius/tests/test_migrations.py | 43 +++++++++++++----------- tests/openwisp2/sample_radius/tests.py | 16 ++++++--- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/openwisp_radius/tests/test_migrations.py b/openwisp_radius/tests/test_migrations.py index 61c1e1a7..de97c380 100644 --- a/openwisp_radius/tests/test_migrations.py +++ b/openwisp_radius/tests/test_migrations.py @@ -11,6 +11,7 @@ from freezegun import freeze_time from openwisp_users.tests.utils import TestOrganizationMixin +from openwisp_utils.tests import capture_any_output from ..migrations import ( _get_first_membership_organization_id, @@ -20,11 +21,13 @@ from .mixins import BaseTestCase RegisteredUser = load_model("RegisteredUser") +RadiusBatch = load_model("RadiusBatch") OrganizationUser = swapper.load_model("openwisp_users", "OrganizationUser") -class TestMigrations(BaseTestCase): - app_label = "openwisp_radius" +class TestMigrationRegisteredUserMultitenancy(BaseTestCase): + def _get_app_label(self): + return RegisteredUser._meta.app_label def _assert_column_not_nullable(self, model_name, field_name): model = load_model(model_name) @@ -81,7 +84,7 @@ def test_multitenant_reverse_keeps_record_with_stronger_method(self): ) migrate_registered_users_multitenant_reverse( - apps, None, app_label=self.app_label + apps, None, app_label=self._get_app_label() ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, stronger_record.pk) @@ -124,7 +127,7 @@ def test_multitenant_reverse_keeps_existing_strongest_record(self): ) migrate_registered_users_multitenant_reverse( - apps, None, app_label=self.app_label + apps, None, app_label=self._get_app_label() ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, strongest_record.pk) @@ -171,7 +174,7 @@ def test_multitenant_reverse_uses_modified_timestamp_as_tiebreaker(self): ) migrate_registered_users_multitenant_reverse( - apps, None, app_label=self.app_label + apps, None, app_label=self._get_app_label() ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, newer_record.pk) @@ -205,7 +208,7 @@ def test_multitenant_reverse_verified_wins_over_method(self): method="email", ) migrate_registered_users_multitenant_reverse( - apps, None, app_label=self.app_label + apps, None, app_label=self._get_app_label() ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, org_weak_method.pk) @@ -236,7 +239,7 @@ def test_multitenant_reverse_equal_strength_keeps_first_record(self): method="email", ) migrate_registered_users_multitenant_reverse( - apps, None, app_label=self.app_label + apps, None, app_label=self._get_app_label() ) self.assertEqual( RegisteredUser.objects.filter(user=user).count(), @@ -278,7 +281,7 @@ def test_multitenant_reverse_method_priority_ordering(self): ) # Rollback: mobile_phone should win (highest method priority) migrate_registered_users_multitenant_reverse( - apps, None, app_label=self.app_label + apps, None, app_label=self._get_app_label() ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.organization, org3) @@ -315,7 +318,7 @@ def test_multitenant_reverse_pending_verification_method_ignored( method="mobile_phone", ) migrate_registered_users_multitenant_reverse( - apps, None, app_label=self.app_label + apps, None, app_label=self._get_app_label() ) surviving_record = RegisteredUser.objects.get(user=user) self.assertEqual(surviving_record.pk, strong_record.pk) @@ -347,7 +350,7 @@ def test_multitenant_reverse_full_cleanup(self): 2, ) migrate_registered_users_multitenant_reverse( - apps, None, app_label=self.app_label + apps, None, app_label=self._get_app_label() ) self.assertEqual( RegisteredUser.objects.filter(user=user1).count(), @@ -360,8 +363,6 @@ def test_multitenant_reverse_full_cleanup(self): class TestPhoneTokenOrganizationPopulateResolution(BaseTestCase): - app_label = "openwisp_radius" - def _set_org_user_created(self, org_user, created): OrganizationUser.objects.filter(pk=org_user.pk).update(created=created) org_user.refresh_from_db(fields=["created"]) @@ -394,18 +395,19 @@ def test_get_first_membership_returns_none_without_membership(self): class TestMigrationRadiusBatchJsonField(TestOrganizationMixin, TestCase): - app_label = "openwisp_radius" migration_path = "openwisp_radius.migrations.0044_convert_user_credentials_data" - radius_batch_model = load_model("RadiusBatch") + + def _get_app_label(self): + return RadiusBatch._meta.app_label def _get_convert_user_credentials_data(self): migration_module = importlib.import_module(self.migration_path) return migration_module.convert_user_credentials_data def _get_model(self, app_label, model_name): - self.assertEqual(app_label, self.app_label) + self.assertEqual(app_label, self._get_app_label()) self.assertEqual(model_name, "RadiusBatch") - return self.radius_batch_model + return RadiusBatch def _get_apps(self): apps = MagicMock() @@ -423,28 +425,29 @@ def _convert_user_credentials_data(self): def test_convert_user_credentials_data(self): org = self._get_org() - batch = self.radius_batch_model.objects.create( + batch = RadiusBatch.objects.create( name="test_batch_migration", strategy="prefix", prefix="test", organization=org, ) - self.radius_batch_model.objects.filter(pk=batch.pk).update( + RadiusBatch.objects.filter(pk=batch.pk).update( user_credentials=json.dumps({"user1": "pass1"}) ) self._convert_user_credentials_data() batch.refresh_from_db() self.assertEqual(batch.user_credentials, {"user1": "pass1"}) + @capture_any_output() def test_convert_user_credentials_data_invalid_json(self): org = self._get_org() - batch = self.radius_batch_model.objects.create( + batch = RadiusBatch.objects.create( name="test_batch_invalid", strategy="prefix", prefix="test2", organization=org, ) - self.radius_batch_model.objects.filter(pk=batch.pk).update( + RadiusBatch.objects.filter(pk=batch.pk).update( user_credentials="invalid_json_string" ) self._convert_user_credentials_data() diff --git a/tests/openwisp2/sample_radius/tests.py b/tests/openwisp2/sample_radius/tests.py index e987742e..7e509c87 100644 --- a/tests/openwisp2/sample_radius/tests.py +++ b/tests/openwisp2/sample_radius/tests.py @@ -1,3 +1,4 @@ +from openwisp_radius.tests import test_migrations as base_migration_tests from openwisp_radius.tests.test_admin import TestAdmin as BaseTestAdmin from openwisp_radius.tests.test_api.test_api import TestApi as BaseTestApi from openwisp_radius.tests.test_api.test_freeradius_api import ( @@ -31,7 +32,6 @@ TestCSVUpload as BaseTestCSVUpload, ) from openwisp_radius.tests.test_commands import TestCommands as BaseTestCommands -from openwisp_radius.tests.test_migrations import TestMigrations as BaseTestMigrations from openwisp_radius.tests.test_models import TestNas as BaseTestNas from openwisp_radius.tests.test_models import ( TestPrivateCsvFile as BaseTestPrivateCsvFile, @@ -186,8 +186,16 @@ class TestLoginView(BaseTestLoginView): pass -class TestMigrations(BaseTestMigrations): - app_label = "sample_radius" +class TestMigrationRegisteredUserMultitenancy( + base_migration_tests.TestMigrationRegisteredUserMultitenancy, +): + pass + + +class TestPhoneTokenOrganizationPopulateResolution( + base_migration_tests.TestPhoneTokenOrganizationPopulateResolution, +): + pass del BaseTestAdmin @@ -219,4 +227,4 @@ class TestMigrations(BaseTestMigrations): del BaseTestUpgradeFromDjangoFreeradius del BaseTestAssertionConsumerServiceView del BaseTestLoginView -del BaseTestMigrations +del base_migration_tests From 7d9601d29c29d20f24468248fc9971eab471c4af Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 19 May 2026 01:58:32 +0530 Subject: [PATCH 38/45] [fix] Admin filters, monitoring metrics and email address validation --- openwisp_radius/base/admin_filters.py | 21 +++++- .../integrations/monitoring/tasks.py | 37 +++++++---- .../monitoring/tests/test_metrics.py | 65 +++++++++++++++++++ openwisp_radius/saml/views.py | 21 +++--- openwisp_radius/tests/test_admin.py | 12 ++-- 5 files changed, 128 insertions(+), 28 deletions(-) diff --git a/openwisp_radius/base/admin_filters.py b/openwisp_radius/base/admin_filters.py index 79211727..e0e1b87a 100644 --- a/openwisp_radius/base/admin_filters.py +++ b/openwisp_radius/base/admin_filters.py @@ -1,7 +1,11 @@ from django.contrib.admin import SimpleListFilter -from django.db.models import Q +from django.db.models import Exists, OuterRef, Q from django.utils.translation import gettext_lazy as _ +from ..utils import load_model + +RegisteredUser = load_model("RegisteredUser") + class RegisteredUserFilter(SimpleListFilter): title = _("Verified") @@ -19,10 +23,23 @@ def queryset(self, request, queryset): return queryset where = Q() if not request.user.is_superuser: - where = Q( + where &= Q( registered_users__organization__in=request.user.organizations_managed ) if self.value() == "unknown": + if not request.user.is_superuser: + # Restrict the "unknown" check to organizations managed by the + # current admin. A plain `registered_users__isnull=True` filter + # would treat users registered in other organizations as known + # and incorrectly exclude them from the results. + registered_users = RegisteredUser.objects.filter( + user=OuterRef("pk"), + organization__in=request.user.organizations_managed, + ) + return queryset.annotate( + has_managed_registered_user=Exists(registered_users) + ).filter(has_managed_registered_user=False) + where &= Q(registered_users__isnull=True) elif self.value(): where &= Q(registered_users__is_verified=self.value() == "true") diff --git a/openwisp_radius/integrations/monitoring/tasks.py b/openwisp_radius/integrations/monitoring/tasks.py index a528b51e..d986afde 100644 --- a/openwisp_radius/integrations/monitoring/tasks.py +++ b/openwisp_radius/integrations/monitoring/tasks.py @@ -3,7 +3,7 @@ from celery import shared_task from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.db.models import Count, Q +from django.db.models import Count, F, Q from django.utils import timezone from swapper import load_model @@ -113,29 +113,33 @@ def _write_user_signup_metrics_for_orgs(metric_key): else: get_metric_func = _get_total_user_signup_metric - # Get the registration data for the past hour. - # The query returns a tuple of organization_id, registration_method and - # count of users who registered with that organization and method. + # Get registration data grouped by organization and registration method. + # Scope OrganizationUser joins to the same organization as the + # RegisteredUser to avoid memberships from other organizations affecting + # this organization's signup metrics. registered_users_query = RegisteredUser.objects.exclude( method="pending_verification" ).exclude( + user__openwisp_users_organizationuser__organization_id=F("organization_id"), user__openwisp_users_organizationuser__created__gt=end_time, ) if metric_key == "user_signups": registered_users_query = registered_users_query.filter( + user__openwisp_users_organizationuser__organization_id=F("organization_id"), user__openwisp_users_organizationuser__created__gt=start_time, user__openwisp_users_organizationuser__created__lte=end_time, ) registered_users = registered_users_query.values_list( - "organization_id", "method" + "organization_id", + "method", ).annotate(count=Count("user_id", distinct=True)) - # There could be users which were manually created (e.g. superuser) - # 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_users__isnull=True + # Count users without a RegisteredUser for this organization. + # A simple ``registered_users__isnull=True`` check would incorrectly + # exclude users having RegisteredUser rows only in other organizations. + users_without_registereduser_query = OrganizationUser.objects.exclude( + user__registered_users__organization_id=F("organization_id") ) if metric_key == "user_signups": users_without_registereduser_query = users_without_registereduser_query.filter( @@ -150,11 +154,22 @@ 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 == "unspecified": - count += users_without_registereduser.get(org_id, 0) + count += users_without_registereduser.pop(org_id, 0) metric = get_metric_func( organization_id=org_id, registration_method=registration_method ) metric_data.append((metric, {"value": count})) + + # Write metrics for organizations having only users without a + # RegisteredUser for that organization. These organizations are not + # present in ``registered_users`` because they have no matching + # RegisteredUser rows. + for org_id, count in users_without_registereduser.items(): + metric = get_metric_func( + organization_id=org_id, registration_method="unspecified" + ) + metric_data.append((metric, {"value": count})) + Metric.batch_write(metric_data) diff --git a/openwisp_radius/integrations/monitoring/tests/test_metrics.py b/openwisp_radius/integrations/monitoring/tests/test_metrics.py index 0b62234c..4c4450ca 100644 --- a/openwisp_radius/integrations/monitoring/tests/test_metrics.py +++ b/openwisp_radius/integrations/monitoring/tests/test_metrics.py @@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.test import tag +from django.utils import timezone from swapper import load_model from openwisp_radius.tests import _RADACCT @@ -16,6 +17,7 @@ TASK_PATH = "openwisp_radius.integrations.monitoring.tasks" RegisteredUser = load_model("openwisp_radius", "RegisteredUser") +OrganizationUser = load_model("openwisp_users", "OrganizationUser") User = get_user_model() @@ -26,6 +28,14 @@ def _read_chart(self, chart, **kwargs): additional_query_kwargs={"additional_params": kwargs}, ) + def _get_metric_traces(self, metric_key, organization_id): + chart = self.metric_model.objects.get(key=metric_key).chart_set.first() + points = self._read_chart( + chart, + organization_id=[str(organization_id)], + ) + return {trace_name: values[-1] for trace_name, values in points["traces"]} + def _assert_pending_verification_excluded(self, points): """ Ensure that pending_verification users do not contribute @@ -701,3 +711,58 @@ def _get_metric_traces(metric_key, organization_id): # org2 only counts its own registration method. self.assertEqual(org2_points.get("email", 0), 1) self.assertEqual(org2_points.get("mobile_phone", 0), 0) + + def test_write_user_registration_metrics_scopes_membership_window_per_org( + self, + ): + """ + Ensure signup metrics scope organization membership windows per organization. + + Scenario: + - One user belongs to two organizations. + - The membership in org1 was created before the metric window. + - The membership in org2 was created within the metric window. + - The user has a RegisteredUser only for org1. + + Expected behavior: + - org1 does not count the user in ``user_signups`` because the + membership is outside the current window. + - org2 counts the user as ``unspecified`` in ``user_signups`` because + the membership is within the current window and no RegisteredUser + exists for org2. + - ``tot_user_signups`` still counts org1 with its registration method. + - org2 must not inherit org1's registration method. + """ + from ..tasks import write_user_registration_metrics + + cache.clear() + create_general_metrics(None, None) + org1 = self._get_org() + org2 = self._create_org(name="org2-window-scope", slug="org2-window-scope") + old_time = timezone.now() - timezone.timedelta(hours=2) + user = self._create_user(date_joined=old_time) + org1_membership = self._create_org_user( + user=user, + organization=org1, + ) + OrganizationUser.objects.filter(pk=org1_membership.pk).update(created=old_time) + self._create_registered_user( + user=user, + organization=org1, + method="mobile_phone", + ) + self._create_org_user( + user=user, + organization=org2, + ) + + write_user_registration_metrics.delay() + + org1_user_signups = self._get_metric_traces("user_signups", org1.pk) + org2_user_signups = self._get_metric_traces("user_signups", org2.pk) + org1_total_signups = self._get_metric_traces("tot_user_signups", org1.pk) + org2_total_signups = self._get_metric_traces("tot_user_signups", org2.pk) + self.assertEqual(org1_user_signups.get("mobile_phone", 0), 0) + self.assertEqual(org2_user_signups.get("unspecified", 0), 1) + self.assertEqual(org1_total_signups.get("mobile_phone", 0), 1) + self.assertEqual(org2_total_signups.get("unspecified", 0), 1) diff --git a/openwisp_radius/saml/views.py b/openwisp_radius/saml/views.py index 53afddd3..74e6beb4 100644 --- a/openwisp_radius/saml/views.py +++ b/openwisp_radius/saml/views.py @@ -95,16 +95,19 @@ def post_login_hook(self, request, user, session_info): user_has_primary_email = EmailAddress.objects.filter( user=user, primary=True ) - email_address, email_created = EmailAddress.objects.get_or_create( - user=user, - email=user.email, - defaults={ - "verified": True, - "primary": not user_has_primary_email.exists(), - }, - ) - if email_created: + try: + email_address = EmailAddress.objects.get( + user=user, email=user.email + ) + except EmailAddress.DoesNotExist: + email_address = EmailAddress( + user=user, + email=user.email, + verified=True, + primary=not user_has_primary_email.exists(), + ) email_address.full_clean() + email_address.save() else: changed_fields = [] if not email_address.verified: diff --git a/openwisp_radius/tests/test_admin.py b/openwisp_radius/tests/test_admin.py index b9b2fb49..4bc8d1da 100644 --- a/openwisp_radius/tests/test_admin.py +++ b/openwisp_radius/tests/test_admin.py @@ -1639,13 +1639,13 @@ def test_registered_user_filter_scoped_to_managed_organizations(self): username="common-user-unverified", email="common-user-unverified@test.com", ) - org2_only = self._create_user( + org2_registered = self._create_user( username="org2-only", email="org2-only@test.com", ) self._create_org_user(user=org1_verified, organization=org1) self._create_org_user(user=common_user_unverified, organization=org1) - self._create_org_user(user=org2_only, organization=org1) + self._create_org_user(user=org2_registered, organization=org1) RegisteredUser.objects.create( user=org1_verified, organization=org1, @@ -1665,7 +1665,7 @@ def test_registered_user_filter_scoped_to_managed_organizations(self): is_verified=True, ) RegisteredUser.objects.create( - user=org2_only, + user=org2_registered, organization=org2, method="mobile_phone", is_verified=True, @@ -1677,15 +1677,15 @@ def test_registered_user_filter_scoped_to_managed_organizations(self): response = self.client.get(url, {"is_verified": "true"}) self.assertContains(response, org1_verified.username) self.assertNotContains(response, common_user_unverified.username) - self.assertNotContains(response, org2_only.username) + self.assertNotContains(response, org2_registered.username) response = self.client.get(url, {"is_verified": "false"}) self.assertContains(response, common_user_unverified.username) self.assertNotContains(response, org1_verified.username) - self.assertNotContains(response, org2_only.username) + self.assertNotContains(response, org2_registered.username) response = self.client.get(url, {"is_verified": "unknown"}) - self.assertNotContains(response, org2_only.username) + self.assertContains(response, org2_registered.username) self.assertNotContains(response, org1_verified.username) self.assertNotContains(response, common_user_unverified.username) From 46e5267d0c3f9b89b4fc0e0e8014d27a17c340f4 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 19 May 2026 02:36:42 +0530 Subject: [PATCH 39/45] [feature] Add user-settable registration methods and validation Co-authored-by: Copilot --- docs/user/settings.rst | 27 ++++++ openwisp_radius/api/serializers.py | 9 +- openwisp_radius/registration.py | 34 +++++++ openwisp_radius/settings.py | 3 + openwisp_radius/tests/test_api/test_api.py | 102 ++++++++++++++++++++- openwisp_radius/tests/test_utils.py | 57 +++++++++++- 6 files changed, 223 insertions(+), 9 deletions(-) diff --git a/docs/user/settings.rst b/docs/user/settings.rst index 3ee5d040..ebbe8d64 100644 --- a/docs/user/settings.rst +++ b/docs/user/settings.rst @@ -717,6 +717,33 @@ verification method. The following choices are available by default: **Disclaimer:** these are just suggestions on possible configurations of OpenWISP RADIUS and must not be considered as legal advice. +``OPENWISP_RADIUS_USER_SETTABLE_REGISTRATION_METHODS`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**Default**: ``["", "email", "mobile_phone"]`` + +Defines which ``RegisteredUser.method`` values can be written by users +through the public registration APIs. + +Methods not included in this setting cannot be selected by users through +those APIs, even if they are present in the full list returned by +``get_registration_choices()``. + +This is especially useful to keep server-assigned provenance methods such +as ``saml``, ``social_login`` or ``manual`` out of user-controlled API +input. These methods may still be assigned internally by server-side +authentication or integration flows when appropriate. + +Example: + +.. code-block:: python + + OPENWISP_RADIUS_USER_SETTABLE_REGISTRATION_METHODS = [ + "", + "email", + "mobile_phone", + ] + .. _openwisp_radius_register_registration_method: Adding support for more registration/verification methods diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 7cbeb211..95eb501e 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -36,7 +36,6 @@ from .. import settings as app_settings from ..base.forms import PasswordResetForm from ..counters.exceptions import SkipCheck -from ..registration import get_registration_choices from ..utils import ( get_group_checks, get_organization_radius_settings, @@ -571,7 +570,7 @@ class RegisterSerializer( 'verification in its "Organization RADIUS Settings."' ), default="", - choices=get_registration_choices(), + choices=app_settings.USER_SETTABLE_REGISTRATION_METHODS, ) def validate_phone_number(self, phone_number): @@ -767,7 +766,7 @@ def save(self): class UpdateRegisteredUserMethodSerializer(ValidatedModelSerializer): method = serializers.ChoiceField( - choices=get_registration_choices(), + choices=app_settings.USER_SETTABLE_REGISTRATION_METHODS, help_text=_( "The registration method to set for the user. " "Cannot be 'pending_verification'." @@ -778,10 +777,6 @@ 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/registration.py b/openwisp_radius/registration.py index 178120ff..554e7024 100644 --- a/openwisp_radius/registration.py +++ b/openwisp_radius/registration.py @@ -3,6 +3,7 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.translation import gettext_lazy as _ +from . import settings as app_settings from .utils import load_model REGISTRATION_METHOD_CHOICES = [ @@ -59,3 +60,36 @@ def unregister_registration_method(name, fail_loud=True): def get_registration_choices(): return REGISTRATION_METHOD_CHOICES + + +def validate_user_settable_registration_methods(methods): + if not isinstance(methods, (list, tuple)): + raise ImproperlyConfigured( + "OPENWISP_RADIUS_USER_SETTABLE_REGISTRATION_METHODS must be a list or tuple" + ) + methods = list(methods) + duplicates = [] + seen = set() + for method in methods: + if method in seen and method not in duplicates: + duplicates.append(method) + seen.add(method) + if duplicates: + raise ImproperlyConfigured( + "OPENWISP_RADIUS_USER_SETTABLE_REGISTRATION_METHODS contains duplicate " + f"values: {', '.join(repr(method) for method in duplicates)}" + ) + available_choices = dict(get_registration_choices()) + invalid_methods = [method for method in methods if method not in available_choices] + if invalid_methods: + raise ImproperlyConfigured( + "OPENWISP_RADIUS_USER_SETTABLE_REGISTRATION_METHODS contains unknown " + f"values: {', '.join(repr(method) for method in invalid_methods)}" + ) + + return [(method, available_choices[method]) for method in methods] + + +validate_user_settable_registration_methods( + app_settings.USER_SETTABLE_REGISTRATION_METHODS +) diff --git a/openwisp_radius/settings.py b/openwisp_radius/settings.py index dea0d461..6d67f7e9 100644 --- a/openwisp_radius/settings.py +++ b/openwisp_radius/settings.py @@ -95,6 +95,9 @@ def get_default_password_reset_url(urls): ALLOW_FIXED_LINE_OR_MOBILE = get_settings_value("ALLOW_FIXED_LINE_OR_MOBILE", False) REGISTRATION_API_ENABLED = get_settings_value("REGISTRATION_API_ENABLED", True) NEEDS_IDENTITY_VERIFICATION = get_settings_value("NEEDS_IDENTITY_VERIFICATION", False) +USER_SETTABLE_REGISTRATION_METHODS = get_settings_value( + "USER_SETTABLE_REGISTRATION_METHODS", ["", "email", "mobile_phone"] +) SMS_MESSAGE_TEMPLATE = get_settings_value( "SMS_MESSAGE_TEMPLATE", _("{organization} verification code: {code}") ) diff --git a/openwisp_radius/tests/test_api/test_api.py b/openwisp_radius/tests/test_api/test_api.py index 516b62a5..81810e4d 100644 --- a/openwisp_radius/tests/test_api/test_api.py +++ b/openwisp_radius/tests/test_api/test_api.py @@ -28,6 +28,8 @@ from openwisp_radius.api.serializers import ( RadiusUserGroupSerializer, RadiusUserSerializer, + RegisterSerializer, + UpdateRegisteredUserMethodSerializer, UserGroupCheckSerializer, ) from openwisp_utils.tests import capture_any_output, capture_stderr @@ -564,6 +566,54 @@ def test_register_verification_field(self): self.assertEqual(r.status_code, 201) self.assertEqual(User.objects.count(), 2) + def test_register_serializer_user_settable_methods(self): + url = reverse("radius:rest_register", args=[self.default_org.slug]) + for method in ["saml", "social_login"]: + with self.subTest(f"RegisterSerializer rejects {method}"): + response = self.client.post( + url, + { + "username": f"{method}@example.com", + "email": f"{method}@example.com", + "password1": "password", + "password2": "password", + "method": method, + }, + ) + self.assertEqual(response.status_code, 400) + self.assertIn( + '"{input}" is not a valid choice.'.format(input=method), + response.data["method"], + ) + + with self.subTest("custom configured method is accepted"): + with mock.patch.object( + app_settings, + "USER_SETTABLE_REGISTRATION_METHODS", + ["", "email", "manual"], + ): + serializer = RegisterSerializer(context={"view": mock.MagicMock()}) + self.assertEqual( + list(serializer.fields["method"].choices.keys()), + ["", "email", "manual"], + ) + response = self.client.post( + url, + { + "username": "manual@example.com", + "email": "manual@example.com", + "password1": "password", + "password2": "password", + "method": "manual", + }, + ) + self.assertEqual(response.status_code, 201) + registered_user = RegisteredUser.objects.get( + user__username="manual@example.com", + organization=self.default_org, + ) + self.assertEqual(registered_user.method, "manual") + @override_settings( ACCOUNT_EMAIL_VERIFICATION="mandatory", ACCOUNT_EMAIL_REQUIRED=True ) @@ -1651,7 +1701,7 @@ def test_update_registered_user_method_with_valid_methods(self): username_suffix="_valid" ) url = self._get_update_method_url(org2) - for method in ["", "manual", "email", "mobile_phone"]: + for method in ["", "email", "mobile_phone"]: with self.subTest(method=method): registered_user = RegisteredUser.objects.get( user=user, organization=org2 @@ -1666,6 +1716,56 @@ def test_update_registered_user_method_with_valid_methods(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["method"], method) + @mock.patch.object( + app_settings, + "USER_SETTABLE_REGISTRATION_METHODS", + ["", "email", "mobile_phone"], + ) + def test_update_registered_user_method_user_settable_methods(self): + _, org2, user_token = self._create_pending_verification_user( + username_suffix="_choices" + ) + url = self._get_update_method_url(org2) + + with self.subTest("default field choices"): + serializer = UpdateRegisteredUserMethodSerializer() + self.assertEqual( + list(serializer.fields["method"].choices.keys()), + ["", "email", "mobile_phone"], + ) + + for method in ["saml", "social_login"]: + with self.subTest(f"UpdateRegisteredUserMethodSerializer rejects {method}"): + response = self.client.post( + url, + {"method": method}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 400) + self.assertIn( + '"{input}" is not a valid choice.'.format(input=method), + response.data["method"], + ) + + with self.subTest("custom configured method is accepted"): + with mock.patch.object( + app_settings, + "USER_SETTABLE_REGISTRATION_METHODS", + ["", "email", "manual"], + ): + serializer = UpdateRegisteredUserMethodSerializer() + self.assertEqual( + list(serializer.fields["method"].choices.keys()), + ["", "email", "manual"], + ) + response = self.client.post( + url, + {"method": "manual"}, + HTTP_AUTHORIZATION=f"Bearer {user_token.key}", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["method"], "manual") + 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) diff --git a/openwisp_radius/tests/test_utils.py b/openwisp_radius/tests/test_utils.py index d501cd06..5882d4ab 100644 --- a/openwisp_radius/tests/test_utils.py +++ b/openwisp_radius/tests/test_utils.py @@ -1,7 +1,12 @@ from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.test import override_settings +from ..registration import ( + register_registration_method, + unregister_registration_method, + validate_user_settable_registration_methods, +) from ..utils import find_available_username, get_one_time_login_url, validate_csvfile from . import FileMixin from .mixins import BaseTestCase @@ -51,3 +56,53 @@ def test_validate_csvfile(self): def test_get_one_time_login_url(self): login_url = get_one_time_login_url(None, None) self.assertEqual(login_url, None) + + def test_validate_user_settable_registration_methods(self): + with self.subTest("default methods are valid and preserve order"): + choices = validate_user_settable_registration_methods( + ["", "email", "mobile_phone"] + ) + self.assertEqual( + choices, + [ + ("", "Unspecified"), + ("email", "Email"), + ("mobile_phone", "Mobile phone"), + ], + ) + + with self.subTest("non list or tuple is rejected"): + with self.assertRaises(ImproperlyConfigured) as error: + validate_user_settable_registration_methods("email") + self.assertEqual( + "list or tuple" in str(error.exception), + True, + ) + + with self.subTest("duplicate methods are rejected"): + with self.assertRaises(ImproperlyConfigured) as error: + validate_user_settable_registration_methods(["email", "email"]) + self.assertEqual("duplicate" in str(error.exception), True) + + with self.subTest("unknown methods are rejected"): + with self.assertRaises(ImproperlyConfigured) as error: + validate_user_settable_registration_methods(["not_registered_method"]) + self.assertEqual("unknown" in str(error.exception), True) + + custom_method = "custom_identity" + register_registration_method(custom_method, "Custom Identity") + try: + with self.subTest("custom registered methods are accepted"): + choices = validate_user_settable_registration_methods( + ["", custom_method, "email"] + ) + self.assertEqual( + choices, + [ + ("", "Unspecified"), + (custom_method, "Custom Identity"), + ("email", "Email"), + ], + ) + finally: + unregister_registration_method(custom_method) From 06687cf421bf1ef623dba4fdfd468b8d2262ff37 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 19 May 2026 02:50:27 +0530 Subject: [PATCH 40/45] [fix] Update registration method choices initialization in serializers --- openwisp_radius/api/serializers.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 95eb501e..8760f4bb 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -570,9 +570,15 @@ class RegisterSerializer( 'verification in its "Organization RADIUS Settings."' ), default="", - choices=app_settings.USER_SETTABLE_REGISTRATION_METHODS, + choices=(), ) + def __init__(self, instance=None, data=..., **kwargs): + super().__init__(instance, data, **kwargs) + self.fields["method"].choices = ( + app_settings.USER_SETTABLE_REGISTRATION_METHODS + ) + def validate_phone_number(self, phone_number): org = self.context["view"].organization if get_organization_radius_settings(org, "sms_verification"): @@ -777,6 +783,12 @@ class Meta: model = RegisteredUser fields = ["method"] + def __init__(self, instance=None, data=..., **kwargs): + super().__init__(instance, data, **kwargs) + self.fields["method"].choices = ( + app_settings.USER_SETTABLE_REGISTRATION_METHODS + ) + def validate_method(self, value): if value == "pending_verification": raise serializers.ValidationError( From 938b852d27967f10f042e166034f2cde34f73be1 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 19 May 2026 02:55:32 +0530 Subject: [PATCH 41/45] [fix] Fixed QA issues --- openwisp_radius/api/serializers.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index 8760f4bb..a3649473 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -575,9 +575,7 @@ class RegisterSerializer( def __init__(self, instance=None, data=..., **kwargs): super().__init__(instance, data, **kwargs) - self.fields["method"].choices = ( - app_settings.USER_SETTABLE_REGISTRATION_METHODS - ) + self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS def validate_phone_number(self, phone_number): org = self.context["view"].organization @@ -785,9 +783,7 @@ class Meta: def __init__(self, instance=None, data=..., **kwargs): super().__init__(instance, data, **kwargs) - self.fields["method"].choices = ( - app_settings.USER_SETTABLE_REGISTRATION_METHODS - ) + self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS def validate_method(self, value): if value == "pending_verification": From fd60fba5237ef7f08af148c09bcaabb13521fba3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 19 May 2026 11:21:27 +0530 Subject: [PATCH 42/45] [fix] Fixed user-settable registration methods validation and checks Co-authored-by: Copilot --- openwisp_radius/api/serializers.py | 8 +-- openwisp_radius/checks.py | 20 ++++++ openwisp_radius/registration.py | 5 -- openwisp_radius/tests/test_checks.py | 92 ++++++++++++++++++++++++++++ openwisp_radius/tests/test_utils.py | 57 +---------------- 5 files changed, 117 insertions(+), 65 deletions(-) diff --git a/openwisp_radius/api/serializers.py b/openwisp_radius/api/serializers.py index a3649473..4da53580 100644 --- a/openwisp_radius/api/serializers.py +++ b/openwisp_radius/api/serializers.py @@ -573,8 +573,8 @@ class RegisterSerializer( choices=(), ) - def __init__(self, instance=None, data=..., **kwargs): - super().__init__(instance, data, **kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS def validate_phone_number(self, phone_number): @@ -781,8 +781,8 @@ class Meta: model = RegisteredUser fields = ["method"] - def __init__(self, instance=None, data=..., **kwargs): - super().__init__(instance, data, **kwargs) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) self.fields["method"].choices = app_settings.USER_SETTABLE_REGISTRATION_METHODS def validate_method(self, value): diff --git a/openwisp_radius/checks.py b/openwisp_radius/checks.py index 58e55048..2bf1d325 100644 --- a/openwisp_radius/checks.py +++ b/openwisp_radius/checks.py @@ -1,6 +1,8 @@ from django.core import checks +from django.core.exceptions import ImproperlyConfigured from . import settings as app_settings +from .registration import validate_user_settable_registration_methods @checks.register @@ -49,3 +51,21 @@ def check_social_registration_enabled(app_configs, **kwargs): ) ) return errors + + +@checks.register +def check_user_settable_registration_methods(app_configs, **kwargs): + errors = [] + try: + validate_user_settable_registration_methods( + app_settings.USER_SETTABLE_REGISTRATION_METHODS + ) + except ImproperlyConfigured as error: + errors.append( + checks.Error( + msg="Improperly Configured", + hint=str(error), + obj="Settings", + ) + ) + return errors diff --git a/openwisp_radius/registration.py b/openwisp_radius/registration.py index 554e7024..dd971a8b 100644 --- a/openwisp_radius/registration.py +++ b/openwisp_radius/registration.py @@ -88,8 +88,3 @@ def validate_user_settable_registration_methods(methods): ) return [(method, available_choices[method]) for method in methods] - - -validate_user_settable_registration_methods( - app_settings.USER_SETTABLE_REGISTRATION_METHODS -) diff --git a/openwisp_radius/tests/test_checks.py b/openwisp_radius/tests/test_checks.py index 7b3333ee..3106a85c 100644 --- a/openwisp_radius/tests/test_checks.py +++ b/openwisp_radius/tests/test_checks.py @@ -1,8 +1,14 @@ from unittest.mock import patch +from django.core.exceptions import ImproperlyConfigured from django.test import TestCase from openwisp_radius import checks +from openwisp_radius.registration import ( + register_registration_method, + unregister_registration_method, + validate_user_settable_registration_methods, +) class TestChecks(TestCase): @@ -31,3 +37,89 @@ def test_check_saml_registration_enabled(self): error = error_list.pop() self.assertEqual(error.msg, "Improperly Configured") self.assertIn("OPENWISP_RADIUS_SAML_REGISTRATION_ENABLED", error.hint) + + def test_check_user_settable_registration_methods(self): + with self.subTest("default methods are valid and preserve order"): + choices = validate_user_settable_registration_methods( + ["", "email", "mobile_phone"] + ) + self.assertEqual( + choices, + [ + ("", "Unspecified"), + ("email", "Email"), + ("mobile_phone", "Mobile phone"), + ], + ) + with patch( + "openwisp_radius.settings.USER_SETTABLE_REGISTRATION_METHODS", + ["", "email", "mobile_phone"], + ): + error_list = checks.check_user_settable_registration_methods(None) + self.assertEqual(len(error_list), 0) + + with self.subTest("non list or tuple is rejected"): + with self.assertRaises(ImproperlyConfigured) as error: + validate_user_settable_registration_methods("email") + self.assertEqual("list or tuple" in str(error.exception), True) + with patch( + "openwisp_radius.settings.USER_SETTABLE_REGISTRATION_METHODS", + "email", + ): + error_list = checks.check_user_settable_registration_methods(None) + self.assertEqual(len(error_list), 1) + self.assertEqual(error_list[0].msg, "Improperly Configured") + self.assertEqual( + "list or tuple" in error_list[0].hint, + True, + ) + + with self.subTest("duplicate methods are rejected"): + with self.assertRaises(ImproperlyConfigured) as error: + validate_user_settable_registration_methods(["email", "email"]) + self.assertEqual("duplicate" in str(error.exception), True) + with patch( + "openwisp_radius.settings.USER_SETTABLE_REGISTRATION_METHODS", + ["email", "email"], + ): + error_list = checks.check_user_settable_registration_methods(None) + self.assertEqual(len(error_list), 1) + self.assertEqual(error_list[0].msg, "Improperly Configured") + self.assertEqual("duplicate" in error_list[0].hint, True) + + with self.subTest("unknown methods are rejected"): + with self.assertRaises(ImproperlyConfigured) as error: + validate_user_settable_registration_methods(["not_registered_method"]) + self.assertEqual("unknown" in str(error.exception), True) + with patch( + "openwisp_radius.settings.USER_SETTABLE_REGISTRATION_METHODS", + ["not_registered_method"], + ): + error_list = checks.check_user_settable_registration_methods(None) + self.assertEqual(len(error_list), 1) + self.assertEqual(error_list[0].msg, "Improperly Configured") + self.assertEqual("unknown" in error_list[0].hint, True) + + custom_method = "custom_identity" + register_registration_method(custom_method, "Custom Identity", fail_loud=False) + try: + with self.subTest("custom registered methods are accepted"): + choices = validate_user_settable_registration_methods( + ["", custom_method, "email"] + ) + self.assertEqual( + choices, + [ + ("", "Unspecified"), + (custom_method, "Custom Identity"), + ("email", "Email"), + ], + ) + with patch( + "openwisp_radius.settings.USER_SETTABLE_REGISTRATION_METHODS", + [custom_method], + ): + error_list = checks.check_user_settable_registration_methods(None) + self.assertEqual(len(error_list), 0) + finally: + unregister_registration_method(custom_method, fail_loud=False) diff --git a/openwisp_radius/tests/test_utils.py b/openwisp_radius/tests/test_utils.py index 5882d4ab..d501cd06 100644 --- a/openwisp_radius/tests/test_utils.py +++ b/openwisp_radius/tests/test_utils.py @@ -1,12 +1,7 @@ from django.contrib.auth import get_user_model -from django.core.exceptions import ImproperlyConfigured, ValidationError +from django.core.exceptions import ValidationError from django.test import override_settings -from ..registration import ( - register_registration_method, - unregister_registration_method, - validate_user_settable_registration_methods, -) from ..utils import find_available_username, get_one_time_login_url, validate_csvfile from . import FileMixin from .mixins import BaseTestCase @@ -56,53 +51,3 @@ def test_validate_csvfile(self): def test_get_one_time_login_url(self): login_url = get_one_time_login_url(None, None) self.assertEqual(login_url, None) - - def test_validate_user_settable_registration_methods(self): - with self.subTest("default methods are valid and preserve order"): - choices = validate_user_settable_registration_methods( - ["", "email", "mobile_phone"] - ) - self.assertEqual( - choices, - [ - ("", "Unspecified"), - ("email", "Email"), - ("mobile_phone", "Mobile phone"), - ], - ) - - with self.subTest("non list or tuple is rejected"): - with self.assertRaises(ImproperlyConfigured) as error: - validate_user_settable_registration_methods("email") - self.assertEqual( - "list or tuple" in str(error.exception), - True, - ) - - with self.subTest("duplicate methods are rejected"): - with self.assertRaises(ImproperlyConfigured) as error: - validate_user_settable_registration_methods(["email", "email"]) - self.assertEqual("duplicate" in str(error.exception), True) - - with self.subTest("unknown methods are rejected"): - with self.assertRaises(ImproperlyConfigured) as error: - validate_user_settable_registration_methods(["not_registered_method"]) - self.assertEqual("unknown" in str(error.exception), True) - - custom_method = "custom_identity" - register_registration_method(custom_method, "Custom Identity") - try: - with self.subTest("custom registered methods are accepted"): - choices = validate_user_settable_registration_methods( - ["", custom_method, "email"] - ) - self.assertEqual( - choices, - [ - ("", "Unspecified"), - (custom_method, "Custom Identity"), - ("email", "Email"), - ], - ) - finally: - unregister_registration_method(custom_method) From cff9fa43c68c5abb98b64befb5e965eb58ed19f6 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 19 May 2026 12:01:23 +0530 Subject: [PATCH 43/45] [fix] Fixed QA issues --- openwisp_radius/registration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openwisp_radius/registration.py b/openwisp_radius/registration.py index dd971a8b..4e75b333 100644 --- a/openwisp_radius/registration.py +++ b/openwisp_radius/registration.py @@ -3,7 +3,6 @@ from django.core.exceptions import ImproperlyConfigured from django.utils.translation import gettext_lazy as _ -from . import settings as app_settings from .utils import load_model REGISTRATION_METHOD_CHOICES = [ From fea912fcd20425540aaa2583dcc9f879f02823d3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 19 May 2026 16:06:45 +0530 Subject: [PATCH 44/45] [fix] Add "bank_card" method to user settable methods --- openwisp_radius/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwisp_radius/settings.py b/openwisp_radius/settings.py index 6d67f7e9..ca7adfde 100644 --- a/openwisp_radius/settings.py +++ b/openwisp_radius/settings.py @@ -96,7 +96,7 @@ def get_default_password_reset_url(urls): REGISTRATION_API_ENABLED = get_settings_value("REGISTRATION_API_ENABLED", True) NEEDS_IDENTITY_VERIFICATION = get_settings_value("NEEDS_IDENTITY_VERIFICATION", False) USER_SETTABLE_REGISTRATION_METHODS = get_settings_value( - "USER_SETTABLE_REGISTRATION_METHODS", ["", "email", "mobile_phone"] + "USER_SETTABLE_REGISTRATION_METHODS", ["", "email", "mobile_phone", "bank_card"] ) SMS_MESSAGE_TEMPLATE = get_settings_value( "SMS_MESSAGE_TEMPLATE", _("{organization} verification code: {code}") From a031f3893b8bb0f2405bf2ac8435d23258b66202 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 20 May 2026 19:44:14 +0530 Subject: [PATCH 45/45] [fix] Fixed default for USER_SETTABLE_REGISTRATION_METHODS --- openwisp_radius/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openwisp_radius/settings.py b/openwisp_radius/settings.py index ca7adfde..6d67f7e9 100644 --- a/openwisp_radius/settings.py +++ b/openwisp_radius/settings.py @@ -96,7 +96,7 @@ def get_default_password_reset_url(urls): REGISTRATION_API_ENABLED = get_settings_value("REGISTRATION_API_ENABLED", True) NEEDS_IDENTITY_VERIFICATION = get_settings_value("NEEDS_IDENTITY_VERIFICATION", False) USER_SETTABLE_REGISTRATION_METHODS = get_settings_value( - "USER_SETTABLE_REGISTRATION_METHODS", ["", "email", "mobile_phone", "bank_card"] + "USER_SETTABLE_REGISTRATION_METHODS", ["", "email", "mobile_phone"] ) SMS_MESSAGE_TEMPLATE = get_settings_value( "SMS_MESSAGE_TEMPLATE", _("{organization} verification code: {code}")