diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 14050f85..c704f3cf 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -8,6 +8,7 @@ import typing as t from datetime import timedelta +from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -54,6 +55,7 @@ def delete(self, wait: t.Optional[timedelta] = None): # Type hints for Django's runtime-generated fields. id: int pk: int + DoesNotExist: t.Type[ObjectDoesNotExist] # Default for how long to wait before a model is deleted. delete_wait = timedelta(days=3) @@ -81,7 +83,6 @@ def delete(self, wait: t.Optional[timedelta] = None): ), ) - # pylint: disable-next=missing-class-docstring class Meta(TypedModelMeta): abstract = True diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index 2da7e4be..2b504c9c 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -13,6 +13,7 @@ from django.db.models import Q from django.db.models.query import QuerySet from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta from ...models import AbstractModel from . import class_student_join_request as _class_student_join_request @@ -24,18 +25,47 @@ class Student(AbstractModel): """A user's student profile.""" - class Manager(models.Manager): # pylint: disable=missing-class-docstring - def create(self, direct_login_key: str, **fields): - return super().create( - **fields, - direct_login_key=make_password(direct_login_key), - ) + # pylint: disable-next=missing-class-docstring + class Manager(models.Manager["Student"]): + def create( # type: ignore[override] + self, + auto_gen_password: t.Optional[str] = None, + **fields, + ): + """Create a student. + + Args: + auto_gen_password: The student's auto-generated password. + + Returns: + A student instance. + """ + + if auto_gen_password: + auto_gen_password = make_password(auto_gen_password) + + return super().create(**fields, auto_gen_password=auto_gen_password) + + def bulk_create( # type: ignore[override] + self, + students: t.Iterable["Student"], + *args, + **kwargs, + ): + """Bulk create students. + + Args: + students: An iteration of student objects. + + Returns: + A list of student instances. + """ - def bulk_create(self, students: t.Iterable["Student"], *args, **kwargs): for student in students: - student.direct_login_key = make_password( - student.direct_login_key - ) + if student.auto_gen_password: + student.auto_gen_password = make_password( + student.auto_gen_password + ) return super().bulk_create(students, *args, **kwargs) @@ -60,6 +90,17 @@ def bulk_create_users( *args, **kwargs, ): + """Bulk create users with student profiles. + + Args: + student_users: A list of tuples where the first object is the + student profile and the second is the user whom the student + profile belongs to. + + Returns: + A list of users that have been assigned their student profile. + """ + students = [student for (student, _) in student_users] users = [user for (_, user) in student_users] @@ -70,56 +111,62 @@ def bulk_create_users( return _user.User.objects.bulk_create(users, *args, **kwargs) - def make_random_direct_login_key(self): - return _user.User.objects.make_random_password( - length=Student.direct_login_key.max_length - ) - - objects: Manager = Manager() + objects: Manager = Manager.from_queryset( # type: ignore[misc] + AbstractModel.QuerySet + )() # type: ignore[assignment] user: "_user.User" - class_join_requests: QuerySet[ - "_class_student_join_request.ClassStudentJoinRequest" - ] + # class_join_requests: QuerySet[ + # "_class_student_join_request.ClassStudentJoinRequest" + # ] # Is this needed or can it be inferred from klass. - school: "_school.School" = models.ForeignKey( - "user.School", - related_name="students", - null=True, + # school: "_school.School" = models.ForeignKey( + # "user.School", + # related_name="students", + # null=True, + # editable=False, + # on_delete=models.CASCADE, + # ) + + # klass: "_class.Class" = models.ForeignKey( + # "user.Class", + # related_name="students", + # null=True, + # editable=False, + # on_delete=models.CASCADE, + # ) + + auto_gen_password = models.CharField( + _("automatically generated password"), + max_length=64, editable=False, - on_delete=models.CASCADE, - ) - - klass: "_class.Class" = models.ForeignKey( - "user.Class", - related_name="students", null=True, - editable=False, - on_delete=models.CASCADE, - ) - - second_password = models.CharField( # TODO: make nullable - _("secondary password"), - max_length=64, # investigate hash length - editable=False, help_text=_( - "A unique key that allows a student to log directly into their" - "account." # TODO + "An auto-generated password that allows student to log directly" + " into their account." ), validators=[MinLengthValidator(64)], ) # TODO: add direct reference to teacher - # TODO: add meta constraint for school & direct_login_key - class Meta: + class Meta(TypedModelMeta): + verbose_name = _("student") + verbose_name_plural = _("students") constraints = [ models.CheckConstraint( check=( Q(school__isnull=True, klass__isnull=True) | Q(school__isnull=False, klass__isnull=False) ), - name="student__school_is_null_and_class_is_null", + name="student__school_and_klass", + ), + models.CheckConstraint( + check=( + Q(school__isnull=False, auto_gen_password__isnull=False) + | Q(school__isnull=True, auto_gen_password__isnull=True) + ), + name="student__auto_gen_password", ), ] diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py index 19dffdbc..5d05d0cc 100644 --- a/codeforlife/user/models/teacher.py +++ b/codeforlife/user/models/teacher.py @@ -10,6 +10,7 @@ from django.db import models from django.db.models.query import QuerySet from django.utils.translation import gettext_lazy as _ +from django_stubs_ext.db.models import TypedModelMeta from ...models import AbstractModel @@ -64,3 +65,7 @@ def create_user(self, teacher: t.Dict[str, t.Any], **fields): ) # TODO: add direct reference to students + + class Meta(TypedModelMeta): + verbose_name = _("teacher") + verbose_name_plural = _("teachers") diff --git a/codeforlife/user/models/user.py b/codeforlife/user/models/user.py index 5417872a..5e1012f0 100644 --- a/codeforlife/user/models/user.py +++ b/codeforlife/user/models/user.py @@ -5,26 +5,113 @@ User model. """ -from django.contrib.auth.models import AbstractUser, UserManager +import typing as t + +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import ( + AbstractBaseUser, + BaseUserManager, + PermissionsMixin, +) from django.db import models from django.db.models import Q from django.db.models.query import QuerySet +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_stubs_ext.db.models import TypedModelMeta from ...models import AbstractModel - -# from . import student as _student from . import auth_factor as _auth_factor from . import otp_bypass_token as _otp_bypass_token from . import session as _session -from . import teacher as _teacher +from .student import Student +from .teacher import Teacher -class User(AbstractUser, AbstractModel): +class User(AbstractBaseUser, AbstractModel, PermissionsMixin): """A user within the CFL system.""" - objects = UserManager.from_queryset( # type: ignore[misc] + USERNAME_FIELD = "email" + + class Manager(BaseUserManager["User"]): + """ + https://docs.djangoproject.com/en/3.2/topics/auth/customizing/#writing-a-manager-for-a-custom-user-model + + Custom user manager for custom user model. + """ + + def _create_user( + self, + password: str, + email: t.Optional[str] = None, + **fields, + ): + if email: + email = self.normalize_email(email) + + user = User( + **fields, + password=make_password(password), + email=email, + ) + user.save(using=self._db) + return user + + def create_user( + self, + password: str, + email: t.Optional[str] = None, + **fields, + ): + """Create a user. + + https://github.com/django/django/blob/19bc11f636ca2b5b80c3d9ad5b489e43abad52bb/django/contrib/auth/models.py#L149C9-L149C20 + + Args: + password: The user's non-hashed password. + email: The user's email address. + + Returns: + A user instance. + """ + + fields.setdefault("is_staff", False) + fields.setdefault("is_superuser", False) + return self._create_user(password, email, **fields) + + def create_superuser( + self, + password: str, + email: t.Optional[str] = None, + **fields, + ): + """Create a super user. + + https://github.com/django/django/blob/19bc11f636ca2b5b80c3d9ad5b489e43abad52bb/django/contrib/auth/models.py#L154C9-L154C25 + + Args: + password: The user's non-hashed password. + email: The user's email address. + + Raises: + ValueError: If is_staff is not True. + ValueError: If is_superuser is not True. + + Returns: + A user instance. + """ + + fields.setdefault("is_staff", True) + fields.setdefault("is_superuser", True) + + if fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(password, email, **fields) + + objects: Manager = Manager.from_queryset( # type: ignore[misc] AbstractModel.QuerySet )() # type: ignore[assignment] @@ -32,6 +119,48 @@ class User(AbstractUser, AbstractModel): auth_factors: QuerySet["_auth_factor.AuthFactor"] otp_bypass_tokens: QuerySet["_otp_bypass_token.OtpBypassToken"] + first_name = models.CharField( + _("first name"), + max_length=150, + blank=True, + ) + + # QUES: is last name required for teachers? + last_name = models.CharField( + _("last name"), + max_length=150, + null=True, + blank=True, + ) + + email = models.EmailField( + _("email address"), + null=True, + blank=True, + ) + + is_staff = models.BooleanField( + _("staff status"), + default=False, + help_text=_( + "Designates whether the user can log into this admin site." + ), + ) + + is_active = models.BooleanField( + _("active"), + default=False, + help_text=_( + "Designates whether this user should be treated as active." + " Unselect this instead of deleting accounts." + ), + ) + + date_joined = models.DateTimeField( + _("date joined"), + default=timezone.now, + ) + otp_secret = models.CharField( _("OTP secret"), max_length=40, @@ -50,31 +179,52 @@ class User(AbstractUser, AbstractModel): ), ) - # pylint: disable-next=unsubscriptable-object - teacher: models.OneToOneField["_teacher.Teacher"] = models.OneToOneField( - "user.Teacher", + teacher = models.OneToOneField( + Teacher, + null=True, + editable=False, + on_delete=models.CASCADE, + ) + + student = models.OneToOneField( + Student, null=True, editable=False, on_delete=models.CASCADE, ) - # student: "_student.Student" = models.OneToOneField( - # "user.Student", - # null=True, - # editable=False, - # on_delete=models.CASCADE, - # ) - - # class Meta(TypedModelMeta): # pylint: disable=missing-class-docstring - # constraints = [ - # models.CheckConstraint( - # check=( - # Q(teacher__isnull=True, student__isnull=False) - # | Q(teacher__isnull=False, student__isnull=True) - # ), - # name="user__teacher_is_null_or_student_is_null", - # ), - # ] + class Meta(TypedModelMeta): + verbose_name = _("user") + verbose_name_plural = _("users") + constraints = [ + models.CheckConstraint( + check=( + Q(teacher__isnull=True, student__isnull=False) + | Q(teacher__isnull=False, student__isnull=True) + ), + name="user__teacher_or_student", + ), + models.CheckConstraint( + check=( + # pylint: disable-next=unsupported-binary-operation + Q( + teacher__isnull=False, + email__isnull=False, + ) + | Q( + student__isnull=False, + student__school__isnull=False, + email__isnull=True, + ) + | Q( + student__isnull=False, + student__school__isnull=True, + email__isnull=False, + ) + ), + name="user__email", + ), + ] @property def is_authenticated(self):