diff --git a/babybuddy/management/commands/createuser.py b/babybuddy/management/commands/createuser.py new file mode 100644 index 00000000..a14ec161 --- /dev/null +++ b/babybuddy/management/commands/createuser.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +Management utility to create users + +Example usage: + + manage.py createuser \ + --username test \ + --email test@test.test \ + --is-staff +""" +import sys +import getpass + +from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import validate_password +from django.core import exceptions +from django.core.management.base import BaseCommand, CommandError +from django.db import DEFAULT_DB_ALIAS +from django.utils.functional import cached_property +from django.utils.text import capfirst + + +class NotRunningInTTYException(Exception): + pass + + +class Command(BaseCommand): + help = "Used to create a user" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.UserModel = get_user_model() + self.username_field = self.UserModel._meta.get_field( + self.UserModel.USERNAME_FIELD + ) + + def add_arguments(self, parser): + parser.add_argument( + f"--{self.UserModel.USERNAME_FIELD}", + help="Specifies the login for a user.", + ) + parser.add_argument( + "--email", + dest="email", + default="", + help="Specifies the email for the user. Optional.", + ) + parser.add_argument( + "--password", + dest="password", + help="Specifies the password for the user. Optional.", + ) + parser.add_argument( + "--is-staff", + dest="is_staff", + action="store_true", + default=False, + help="Specifies the staff status for the user. Default is False.", + ) + + def handle(self, *args, **options): + username = options.get(self.UserModel.USERNAME_FIELD) + password = options.get("password") + + user_data = {} + user_password = "" + verbose_field_name = self.username_field.verbose_name + + try: + error_msg = self._validate_username( + username, verbose_field_name, DEFAULT_DB_ALIAS + ) + if error_msg: + raise CommandError(error_msg) + + user_data[self.UserModel.USERNAME_FIELD] = username + + # Prompt for a password interactively (if password not set via arg) + while password is None: + password = getpass.getpass() + password2 = getpass.getpass("Password (again): ") + + if password.strip() == "": + self.stderr.write("Error: Blank passwords aren't allowed.") + password = None + # Don't validate blank passwords. + continue + + if password != password2: + self.stderr.write("Error: Your passwords didn't match.") + password = None + password2 = None + # Don't validate passwords that don't match. + continue + + try: + validate_password(password2, self.UserModel(**user_data)) + except exceptions.ValidationError as err: + self.stderr.write("\n".join(err.messages)) + response = input( + "Bypass password validation and create user anyway? [y/N]: " + ) + if response.lower() != "y": + password = None + password2 = None + continue + + user_password = password + + user = self.UserModel._default_manager.db_manager( + DEFAULT_DB_ALIAS + ).create_user(**user_data, password=user_password) + user.email = options.get("email") + user.is_staff = options.get("is_staff") + user.save() + + if options.get("verbosity") > 0: + self.stdout.write(f"User {username} created successfully.") + + except KeyboardInterrupt: + self.stderr.write("\nOperation cancelled.") + sys.exit(1) + except exceptions.ValidationError as e: + raise CommandError("; ".join(e.messages)) + except NotRunningInTTYException: + self.stdout.write( + "User creation skipped due to not running in a TTY. " + "You can run `manage.py createuser` in your project " + "to create one manually." + ) + + @cached_property + def username_is_unique(self): + """ + Check if username is unique. + """ + if self.username_field.unique: + return True + return any( + len(unique_constraint.fields) == 1 + and unique_constraint.fields[0] == self.username_field.name + for unique_constraint in self.UserModel._meta.total_unique_constraints + ) + + def _validate_username(self, username, verbose_field_name, database): + """ + Validate username. If invalid, return a string error message. + """ + if self.username_is_unique: + try: + self.UserModel._default_manager.db_manager(database).get_by_natural_key( + username + ) + except self.UserModel.DoesNotExist: + pass + else: + return f"Error: The {verbose_field_name} is already taken." + if not username: + return f"{capfirst(verbose_field_name)} cannot be blank." + try: + self.username_field.clean(username, None) + except exceptions.ValidationError as e: + return "; ".join(e.messages) diff --git a/babybuddy/tests/tests_commands.py b/babybuddy/tests/tests_commands.py index ccdb70cd..43c18461 100644 --- a/babybuddy/tests/tests_commands.py +++ b/babybuddy/tests/tests_commands.py @@ -22,3 +22,24 @@ class CommandsTestCase(TransactionTestCase): call_command("reset", verbosity=0, interactive=False) self.assertIsInstance(User.objects.get(username="admin"), User) self.assertEqual(Child.objects.count(), 1) + + def test_createuser(self): + call_command( + "createuser", + username="test", + email="test@test.test", + password="test", + verbosity=0, + ) + self.assertIsInstance(User.objects.get(username="test"), User) + self.assertFalse(User.objects.filter(username="test", is_staff=True)) + call_command( + "createuser", + "--is-staff", + username="testadmin", + email="testadmin@testadmin.testadmin", + password="test", + verbosity=0, + ) + self.assertIsInstance(User.objects.get(username="testadmin"), User) + self.assertTrue(User.objects.filter(username="testadmin", is_staff=True)) diff --git a/docs/user-guide/managing-users.md b/docs/user-guide/managing-users.md index a105455b..0f2667ed 100644 --- a/docs/user-guide/managing-users.md +++ b/docs/user-guide/managing-users.md @@ -17,3 +17,39 @@ + +## Creating a User from the Command Line + +There are 2 ways you can create a user from the command line: + +1. Passing user's password as an argument: + +```shell +python manage.py createuser --username --password +``` + +2. Interactively setting user's password: + +```shell +python manage.py createuser --username +``` + +You will then be prompted to enter and confirm a password. + +- If you want to make the user a staff, you can append the `--is-staff` argument: + +```shell +python manage.py createuser --username --is-staff +``` + +- Another argument you can use with this command is `--email` + +```shell +python manage.py createuser --username --email +``` + +- To get a list of supported commands: + +```shell +python manage.py createuser --help +```