Changeset View
Changeset View
Standalone View
Standalone View
bid_api/views/create_user.py
| import itertools | |||||
| import logging | import logging | ||||
| from django.db import transaction, IntegrityError | from django.db import transaction, IntegrityError | ||||
| from django.contrib.auth import get_user_model | from django.contrib.auth import get_user_model | ||||
| from django.contrib.admin.models import LogEntry, ADDITION | from django.contrib.admin.models import LogEntry, ADDITION | ||||
| from django.contrib.contenttypes.models import ContentType | from django.contrib.contenttypes.models import ContentType | ||||
| from django.http import JsonResponse, HttpResponse | from django.http import JsonResponse, HttpResponse | ||||
| from django.utils.decorators import method_decorator | from django.utils.decorators import method_decorator | ||||
| from django.forms import ModelForm | from django.forms import ModelForm | ||||
| from oauth2_provider.decorators import protected_resource | from oauth2_provider.decorators import protected_resource | ||||
| from .abstract import AbstractAPIView | from .abstract import AbstractAPIView | ||||
| UserModel = get_user_model() | UserModel = get_user_model() | ||||
| class CreateUserForm(ModelForm): | class CreateUserForm(ModelForm): | ||||
| """User creation form, using fields from UserModel.""" | """User creation form, using fields from UserModel.""" | ||||
| class Meta: | class Meta: | ||||
| model = UserModel | model = UserModel | ||||
| fields = ["email", "full_name", "password"] | fields = ["email", "full_name", "password"] | ||||
| def random_nums(): | |||||
| """Increasingly larger random number generator.""" | |||||
| import random | |||||
| lower, upper = 1, 5 | |||||
| while True: | |||||
| yield random.randint(lower, upper - 1) | |||||
| lower, upper = upper, upper * 3 | |||||
| class CreateUserView(AbstractAPIView): | class CreateUserView(AbstractAPIView): | ||||
| """API endpoint for creating users. | """API endpoint for creating users. | ||||
| Requires an auth token with 'usercreate' scope to use. | Requires an auth token with 'usercreate' scope to use. | ||||
| """ | """ | ||||
| # Does not require an initial nickname; a unique one is created | # Does not require an initial nickname; a unique one is created | ||||
| # automatically. This simplifies the API a lot, since a uniqueness | # automatically. This simplifies the API a lot, since a uniqueness | ||||
| # constraint violation in the database can be translated directly into a | # constraint violation in the database can be translated directly into a | ||||
| # message "this user already exists". If we were to also allow usernames | # message "this user already exists". If we were to also allow usernames | ||||
| # to be chosen here, there is no such mapping any more, and the number of | # to be chosen here, there is no such mapping any more, and the number of | ||||
| # different failure cases increases. | # different failure cases increases. | ||||
| log = logging.getLogger(f"{__name__}.CreateUser") | log = logging.getLogger(f"{__name__}.CreateUser") | ||||
railla: Just moving the already existing nickname generation elsewhere to make it reusable in the new… | |||||
| def find_unique_nickname(self, cuf: CreateUserForm) -> str: | |||||
| """Return unique nickname based on the user's full name or email.""" | |||||
| import re | |||||
| illegal = re.compile(r"[^\w.+-]") | |||||
| strip_email = re.compile("@.*$") | |||||
| def acceptable_nickname(name: str) -> bool: | |||||
| """Return True iff the nickname is unique.""" | |||||
| count = UserModel.objects.filter(nickname=name).count() | |||||
| return count == 0 | |||||
| full_name = cuf.cleaned_data["full_name"].replace(" ", "-") | |||||
| email = strip_email.sub("", cuf.cleaned_data["email"]) | |||||
| base = full_name or email | |||||
| self.log.debug("Generating unique nickname for base %r", base) | |||||
| base = illegal.sub("", base) | |||||
| if acceptable_nickname(base): | |||||
| return base | |||||
| # Try increasingly larger random numbers as a suffix. | |||||
| for num in itertools.islice(random_nums(), 1000): | |||||
| nickname = f"{base}-{num}" | |||||
| if acceptable_nickname(nickname): | |||||
| return nickname | |||||
| raise ValueError( | |||||
| f"Unable to find unique name for base {base!r} after trying 1000 names" | |||||
| ) | |||||
| @method_decorator(protected_resource(scopes=["usercreate"])) | @method_decorator(protected_resource(scopes=["usercreate"])) | ||||
| @transaction.atomic() | @transaction.atomic() | ||||
| def post(self, request) -> HttpResponse: | def post(self, request) -> HttpResponse: | ||||
| cuf = CreateUserForm(request.POST) | cuf = CreateUserForm(request.POST) | ||||
| if not cuf.is_valid(): | if not cuf.is_valid(): | ||||
| errors = cuf.errors.as_json() | errors = cuf.errors.as_json() | ||||
| self.log.info("invalid form received: %s", errors) | self.log.info("invalid form received: %s", errors) | ||||
| if cuf.has_error("email", "unique"): | if cuf.has_error("email", "unique"): | ||||
| status = 409 | status = 409 | ||||
| else: | else: | ||||
| status = 400 | status = 400 | ||||
| return HttpResponse(errors, content_type="application/json", status=status) | return HttpResponse(errors, content_type="application/json", status=status) | ||||
| self.log.info( | self.log.info( | ||||
| "Creating user %r on behalf of %s", request.POST["email"], request.user | "Creating user %r on behalf of %s", request.POST["email"], request.user | ||||
| ) | ) | ||||
| nickname = self.find_unique_nickname(cuf) | email = cuf.cleaned_data["email"] | ||||
| full_name = cuf.cleaned_data["full_name"] | |||||
| nickname = UserModel.generate_nickname(email=email, full_name=full_name) | |||||
| try: | try: | ||||
| db_user = UserModel.objects.create_user( | db_user = UserModel.objects.create_user( | ||||
| cuf.cleaned_data["email"], | email, | ||||
| cuf.cleaned_data["password"], | cuf.cleaned_data["password"], | ||||
| full_name=cuf.cleaned_data["full_name"], | full_name=full_name, | ||||
| nickname=nickname, | nickname=nickname, | ||||
| ) | ) | ||||
| except IntegrityError as ex: | except IntegrityError as ex: | ||||
| # Even though the user didn't exist when we validated the form, | # Even though the user didn't exist when we validated the form, | ||||
| # it can exist now due to race conditions. | # it can exist now due to race conditions. | ||||
| if "nickname" in str(ex): | if "nickname" in str(ex): | ||||
| self.log.error( | self.log.error( | ||||
| "Error creating user %r with nickname %r on behalf of %s: %s", | "Error creating user %r with nickname %r on behalf of %s: %s", | ||||
| ▲ Show 20 Lines • Show All 54 Lines • Show Last 20 Lines | |||||
Just moving the already existing nickname generation elsewhere to make it reusable in the new registration flow, which no longer requires a nickname.