MyFinances icon indicating copy to clipboard operation
MyFinances copied to clipboard

fix: Change `User` class in `backend/models.py` to use email as uniqu…

Open James-Makela opened this issue 1 year ago • 12 comments

…e field.

Description

Here I have made changes to the User Model in backend/models.py. The changes make the email field unique, and required, rather than the username. At this stage, some other things will still need working out. All local tests have passed so far. I am open to creating some new tests to test the structure and behavior of the user model, but not sure how to go about that yet.

To Do

  • [ ] Fix, Logged in as no longer displaying anything
  • [ ] Fix, Unable to add first and last name after changes
  • [ ] Identify any other bugs introduced

Checklist

  • [x] Ran the Black Formatter and djLint-er on any new code (checks will fail without)
  • [x] Made any changes or additions to the documentation where required
  • [x] Changes generate no new warnings/errors
  • [x] New and existing unit tests pass locally with my changes

What type of PR is this?

  • 🐛 Bug Fix
  • 🚨 Breaking Change

Helpful Info Found Along the Way

Added/updated tests?

  • 🙋 no, because I need help

Related PRs, Issues etc

  • Related Issue #367

James-Makela avatar May 10 '24 02:05 James-Makela

Hi @James-Makela, just wondering if this will be worked on further at any point?

TreyWW avatar May 28 '24 22:05 TreyWW

Hi Trey, unfortunately I am flat out for the next couple of weeks. I could continue after that. However if you or anyone else wants to take over that is cool too.

James-Makela avatar May 29 '24 00:05 James-Makela

Update:

  • The issues with this change I have identified so far seem to go away after refreshing the page once or twice.

Next Steps:

  • Manual testing of program behavior:
    • before the change - from scratch
    • After the change - from scratch
    • After the change - set up user before changing code

Questions:

  • Is changing the user model in this way sufficient(so far seems to be), or do we want to create a custom user class from scratch?

James-Makela avatar Jun 11 '24 03:06 James-Makela

Hey @James-Makela, what you said sounds good. As for the user class, we have a custom one already in models.py so you can override parts of that if that's what you mean?

Let me know if you have any more questions, great work though :)

TreyWW avatar Jun 11 '24 05:06 TreyWW

Hey @James-Makela, what you said sounds good. As for the user class, we have a custom one already in models.py so you can override parts of that if that's what you mean?

Ah yes, that appears to be what I am doing.

I have gone through some testing today, Setting up from scratch after moving to test the branch for this fix.

Tests Involved:

  • Setup according to installation instructions.
    • Identical behavior
  • Create superuser
    • Prompts for email first after fix
  • View profile
    • Some issues with profile display(only showing "logged in as" after refresh), however identical behavior.
  • Change name
    • No issues - Identical behavior
  • Attempt to create another superuser with the same email
    • Unable to do after fix is implemented - user is prompted to enter a unique email
  • Attempt to create another superuser with the same username
    • Handled correctly before fix, however crashes after fix.
      • Doesn't appear to be checking if the username is unique before trying to insert superuser into the db.
      • I will need to figure out where to fix that behavior next.

James-Makela avatar Jun 12 '24 02:06 James-Makela

Hi @James-Makela, sorry I missed the PR update. Anything you need me to do at this point?

I appreciate your patience

TreyWW avatar Jun 30 '24 14:06 TreyWW

Hi Trey,

I made a little progress on this. I have extended the CustomUserManager class, and been able to get the superuser creation working with only email. However going into the admin panel, usernames are still there and all of the forms may need to be tweaked to go without usernames.

Alternatively I have tried to change the CustomUserManager class to also take in a unique username. But I am having trouble understanding this part in the original usermodel, to transfer over to the custom usermodel:

GlobalUserModel = apps.get_model(
            self.model._meta.app_label, self.model._meta.object_name
        )
        username = GlobalUserModel.normalize_username(username)

Here is my current state of the extended CustomUserManager class, mostly taken from the Django's implementation:

class CustomUserManager(BaseUserManager):
    def _create_user(self, email, password, **extra_fields):
        if not email:
            raise ValueError("The given email must be set")
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email, password=None, **extra_fields):
        extra_fields.setdefault("is_staff", False)
        extra_fields.setdefault('is_superuser', False)
        return self._create_user(email, password, **extra_fields)

    def create_superuser(self, email, password=None, **extra_fields):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", True)

        if extra_fields.get("is_staff") is not True:
            raise ValueError("Superuser must have is_staff=True.")
        if extra_fields.get("is_superuser") is not True:
            raise ValueError("Superuser must have is_superuser=True.")

        return self._create_user(email, password, **extra_fields)

    def get_queryset(self):
        return (
            super()
            .get_queryset()
            .select_related("user_profile", "logged_in_as_team")
            .annotate(notification_count=(Count("user_notifications")))
        )

I feel like its almost there but I will need advice on whether you want to go in the direction of no usernames at all, or enforced unique usernames, as well as unique emails, and whether I am hopefully on the right track here.

James-Makela avatar Jun 30 '24 23:06 James-Makela

I'm wondering if in models.py class User you could just add username and email fields manually and add unique=True and Django might do the rest

TreyWW avatar Jul 01 '24 09:07 TreyWW

Trying that, with the whole user class as:

class User(AbstractUser):
    objects = CustomUserManager()  # type: ignore

    email = models.EmailField(max_length=150, unique=True)
    username = models.CharField(max_length=150, unique=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["username"]

    logged_in_as_team = models.ForeignKey("Team", on_delete=models.SET_NULL, null=True, blank=True)
    awaiting_email_verification = models.BooleanField(default=True)

    class Role(models.TextChoices):
        #        NAME     DJANGO ADMIN NAME
        DEV = "DEV", "Developer"
        STAFF = "STAFF", "Staff"
        USER = "USER", "User"
        TESTER = "TESTER", "Tester"

    role = models.CharField(max_length=10, choices=Role.choices, default=Role.USER)

Strangely this results in allowing the user to enter a non-unique username without error, then crashes while trying to store the data in the database:

django.db.utils.IntegrityError: UNIQUE constraint failed: backend_user.username

James-Makela avatar Jul 01 '24 11:07 James-Makela

Ah yeah, that's cause we don't do validation in the create account view. We'd have to just add User.objects.filter and check if one exists

TreyWW avatar Jul 01 '24 11:07 TreyWW

Line 71 backend/views/core/auth/create_account.py should handle it... hmm

TreyWW avatar Jul 01 '24 11:07 TreyWW

I notice backend/views/core/auth/create_account.py at line 81 sets the username to be the email. Changing the overridden _create_user function to do the same seems to work perfectly so far.

_create_user function:

    def _create_user(self, email, password, **extra_fields):
        if not email:
            raise ValueError("The given email must be set")
        email = self.normalize_email(email)
        user = self.model(email=email, username=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

James-Makela avatar Jul 02 '24 01:07 James-Makela