diff --git a/.vscode/settings.json b/.vscode/settings.json index 6ae8e86a..48cc2f5e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,18 @@ { + "black-formatter.path": [ + ".venv/bin/python", + "-m", + "black" + ], "black-formatter.args": [ "--config", "pyproject.toml" ], + "mypy-type-checker.path": [ + ".venv/bin/python", + "-m", + "mypy" + ], "pylint.path": [ ".venv/bin/python", "-m", diff --git a/codeforlife/__init__.py b/codeforlife/__init__.py index a0a545a0..9d552079 100644 --- a/codeforlife/__init__.py +++ b/codeforlife/__init__.py @@ -1,6 +1,12 @@ -from pathlib import Path +""" +© Ocado Group +Created on 09/12/2023 at 11:02:54(+00:00). + +Entry point to the Code for Life package. +""" -import django_stubs_ext +import typing as t +from pathlib import Path from .version import __version__ @@ -11,7 +17,10 @@ BASE_DIR = Path(__file__).resolve().parent DATA_DIR = BASE_DIR.joinpath("data") -django_stubs_ext.monkeypatch() +if t.TYPE_CHECKING: + import django_stubs_ext + + django_stubs_ext.monkeypatch() # ------------------------------------------------------------------------------ diff --git a/codeforlife/models/__init__.py b/codeforlife/models/__init__.py index 430adb51..ca8117dd 100644 --- a/codeforlife/models/__init__.py +++ b/codeforlife/models/__init__.py @@ -17,13 +17,28 @@ from .fields import * -class AbstractModel(models.Model): - """Base model to be inherited by other models throughout the CFL system.""" +class Model(models.Model): + """Provide type hints for general model attributes.""" + + id: int + pk: int + objects: models.Manager + DoesNotExist: t.Type[ObjectDoesNotExist] + + class Meta(TypedModelMeta): + abstract = True + + +AnyModel = t.TypeVar("AnyModel", bound=Model) + + +class WarehouseModel(Model): + """To be inherited by all models whose data is to be warehoused.""" class QuerySet(models.QuerySet): """Custom queryset to support CFL's system's operations.""" - model: "AbstractModel" # type: ignore[assignment] + model: "WarehouseModel" # type: ignore[assignment] def update(self, **kwargs): """Updates all models in the queryset and notes when they were last @@ -44,18 +59,19 @@ def delete(self, wait: t.Optional[timedelta] = None): Args: wait: How long to wait before these model are deleted. If not - set, the class-level default value is used. + set, the class-level default value is used. To delete + immediately, set wait to 0 with timedelta(). """ - wait = wait or self.model.delete_wait - self.update(delete_after=timezone.now() + wait) + if wait is None: + wait = self.model.delete_wait - objects = models.Manager.from_queryset(QuerySet)() + if wait == timedelta(): + super().delete() + else: + self.update(delete_after=timezone.now() + wait) - # Type hints for Django's runtime-generated fields. - id: int - pk: int - DoesNotExist: t.Type[ObjectDoesNotExist] + objects = models.Manager.from_queryset(QuerySet)() # Default for how long to wait before a model is deleted. delete_wait = timedelta(days=3) @@ -89,18 +105,26 @@ class Meta(TypedModelMeta): # pylint: disable-next=arguments-differ def delete( # type: ignore[override] self, + *args, wait: t.Optional[timedelta] = None, + **kwargs, ): """Schedules the deletion of this model. Args: wait: How long to wait before this model is deleted. If not set, the - class-level default value is used. + class-level default value is used. To delete immediately, set + wait to 0 with timedelta(). """ - wait = wait or self.delete_wait - self.delete_after = timezone.now() + wait - self.save() + if wait is None: + wait = self.delete_wait + + if wait == timedelta(): + super().delete(*args, **kwargs) + else: + self.delete_after = timezone.now() + wait + self.save(*args, **kwargs) -AnyModel = t.TypeVar("AnyModel", bound=AbstractModel) +AnyWarehouseModel = t.TypeVar("AnyWarehouseModel", bound=WarehouseModel) diff --git a/codeforlife/tests/model.py b/codeforlife/tests/model.py index 7cf831a5..ff9941f1 100644 --- a/codeforlife/tests/model.py +++ b/codeforlife/tests/model.py @@ -7,11 +7,10 @@ import typing as t -from django.db.models import Model from django.db.utils import IntegrityError from django.test import TestCase -AnyModel = t.TypeVar("AnyModel", bound=Model) +from ..models import AnyModel, Model class ModelTestCase(TestCase, t.Generic[AnyModel]): @@ -38,3 +37,18 @@ def assert_raises_integrity_error(self, *args, **kwargs): """ return self.assertRaises(IntegrityError, *args, **kwargs) + + def assert_does_not_exist(self, model_or_pk: t.Union[AnyModel, t.Any]): + """Asserts the model does not exist. + + Args: + model_or_pk: The model itself or its primary key. + """ + + if isinstance(model_or_pk, Model): + with self.assertRaises(model_or_pk.DoesNotExist): + model_or_pk.refresh_from_db() + else: + model_class = self.get_model_class() + with self.assertRaises(model_class.DoesNotExist): + model_class.objects.get(pk=model_or_pk) diff --git a/codeforlife/user/models/auth_factor.py b/codeforlife/user/models/auth_factor.py index f304b49d..92bb556e 100644 --- a/codeforlife/user/models/auth_factor.py +++ b/codeforlife/user/models/auth_factor.py @@ -9,11 +9,11 @@ from django.utils.translation import gettext_lazy as _ from django_stubs_ext.db.models import TypedModelMeta -from ...models import AbstractModel +from ...models import WarehouseModel from . import user as _user -class AuthFactor(AbstractModel): +class AuthFactor(WarehouseModel): """A user's enabled authentication factors.""" class Type(models.TextChoices): diff --git a/codeforlife/user/models/class_student_join_request.py b/codeforlife/user/models/class_student_join_request.py index 5ad38532..b13608c1 100644 --- a/codeforlife/user/models/class_student_join_request.py +++ b/codeforlife/user/models/class_student_join_request.py @@ -7,13 +7,13 @@ # from django.db import models -# from ...models import AbstractModel +# from ...models import WarehouseModel # from . import klass as _class # from . import student as _student # # TODO: move to portal -# class ClassStudentJoinRequest(AbstractModel): +# class ClassStudentJoinRequest(WarehouseModel): # """A request from a student to join a class.""" # klass: "_class.Class" = models.ForeignKey( diff --git a/codeforlife/user/models/klass.py b/codeforlife/user/models/klass.py index c2b014a8..13145a55 100644 --- a/codeforlife/user/models/klass.py +++ b/codeforlife/user/models/klass.py @@ -13,13 +13,13 @@ from django.utils.translation import gettext_lazy as _ from django_stubs_ext.db.models import TypedModelMeta -from ...models import AbstractModel +from ...models import WarehouseModel from . import school as _school from . import student as _student from . import teacher as _teacher -class Class(AbstractModel): +class Class(WarehouseModel): """A collection of students owned by a teacher.""" pk: str # type: ignore[assignment] diff --git a/codeforlife/user/models/otp_bypass_token.py b/codeforlife/user/models/otp_bypass_token.py index eefea93d..3f8de656 100644 --- a/codeforlife/user/models/otp_bypass_token.py +++ b/codeforlife/user/models/otp_bypass_token.py @@ -15,11 +15,11 @@ from django.utils.translation import gettext_lazy as _ from django_stubs_ext.db.models import TypedModelMeta -from ...models import AbstractModel +from ...models import WarehouseModel from . import user as _user -class OtpBypassToken(AbstractModel): +class OtpBypassToken(WarehouseModel): """ A one-time-use token that a user can use to bypass their OTP auth factor. Each user has a limited number of OTP-bypass tokens. @@ -80,7 +80,7 @@ def key(otp_bypass_token: OtpBypassToken): return super().bulk_create(otp_bypass_tokens, *args, **kwargs) objects: Manager = Manager.from_queryset( # type: ignore[misc] - AbstractModel.QuerySet + WarehouseModel.QuerySet )() # type: ignore[assignment] user: "_user.User" = models.ForeignKey( # type: ignore[assignment] diff --git a/codeforlife/user/models/school.py b/codeforlife/user/models/school.py index e8c92bbd..f3ad70e1 100644 --- a/codeforlife/user/models/school.py +++ b/codeforlife/user/models/school.py @@ -11,14 +11,14 @@ from django.utils.translation import gettext_lazy as _ from django_stubs_ext.db.models import TypedModelMeta -from ...models import AbstractModel +from ...models import WarehouseModel from ...models.fields import Country, UkCounty from . import klass as _class from . import student as _student from . import teacher as _teacher -class School(AbstractModel): +class School(WarehouseModel): """A collection of teachers and students.""" # pylint: disable-next=missing-class-docstring @@ -26,7 +26,7 @@ class Manager(models.Manager["School"]): pass objects: Manager = Manager.from_queryset( # type: ignore[misc] - AbstractModel.QuerySet + WarehouseModel.QuerySet )() # type: ignore[assignment] teachers: QuerySet["_teacher.Teacher"] diff --git a/codeforlife/user/models/school_teacher_invitation.py b/codeforlife/user/models/school_teacher_invitation.py index 91af711f..ff4e9dd6 100644 --- a/codeforlife/user/models/school_teacher_invitation.py +++ b/codeforlife/user/models/school_teacher_invitation.py @@ -11,7 +11,7 @@ # from django.utils import timezone # from django.utils.translation import gettext_lazy as _ -# from ...models import AbstractModel +# from ...models import WarehouseModel # from . import school as _school # from . import teacher as _teacher @@ -21,7 +21,7 @@ # # TODO: move to portal -# class SchoolTeacherInvitation(AbstractModel): +# class SchoolTeacherInvitation(WarehouseModel): # """An invitation for a teacher to join a school.""" # school: "_school.School" = models.ForeignKey( diff --git a/codeforlife/user/models/student.py b/codeforlife/user/models/student.py index bf559ea1..a24b144b 100644 --- a/codeforlife/user/models/student.py +++ b/codeforlife/user/models/student.py @@ -13,13 +13,13 @@ from django.utils.translation import gettext_lazy as _ from django_stubs_ext.db.models import TypedModelMeta -from ...models import AbstractModel +from ...models import WarehouseModel from . import klass as _class from . import school as _school from . import user as _user -class Student(AbstractModel): +class Student(WarehouseModel): """A user's student profile.""" # pylint: disable-next=missing-class-docstring @@ -108,7 +108,7 @@ def bulk_create_users( return _user.User.objects.bulk_create(users, *args, **kwargs) objects: Manager = Manager.from_queryset( # type: ignore[misc] - AbstractModel.QuerySet + WarehouseModel.QuerySet )() # type: ignore[assignment] user: "_user.User" diff --git a/codeforlife/user/models/teacher.py b/codeforlife/user/models/teacher.py index 08d558e6..aa538826 100644 --- a/codeforlife/user/models/teacher.py +++ b/codeforlife/user/models/teacher.py @@ -12,14 +12,14 @@ from django.utils.translation import gettext_lazy as _ from django_stubs_ext.db.models import TypedModelMeta -from ...models import AbstractModel +from ...models import WarehouseModel from . import klass as _class from . import school as _school from . import student as _student from . import user as _user -class Teacher(AbstractModel): +class Teacher(WarehouseModel): """A user's teacher profile.""" # pylint: disable-next=missing-class-docstring @@ -40,7 +40,7 @@ def create_user(self, teacher: t.Dict[str, t.Any], **fields): ) objects: Manager = Manager.from_queryset( # type: ignore[misc] - AbstractModel.QuerySet + WarehouseModel.QuerySet )() # type: ignore[assignment] user: "_user.User" diff --git a/codeforlife/user/models/user.py b/codeforlife/user/models/user.py index d4c1cba4..56214b61 100644 --- a/codeforlife/user/models/user.py +++ b/codeforlife/user/models/user.py @@ -21,7 +21,7 @@ from django.utils.translation import gettext_lazy as _ from django_stubs_ext.db.models import TypedModelMeta -from ...models import AbstractModel +from ...models import WarehouseModel from . import auth_factor as _auth_factor from . import otp_bypass_token as _otp_bypass_token from . import session as _session @@ -29,7 +29,7 @@ from . import teacher as _teacher -class User(AbstractBaseUser, AbstractModel, PermissionsMixin): +class User(AbstractBaseUser, WarehouseModel, PermissionsMixin): """A user within the CFL system.""" USERNAME_FIELD = "email" @@ -108,7 +108,7 @@ def create_superuser(self, password: str, first_name: str, **fields): return self._create_user(password, first_name=first_name, **fields) objects: Manager = Manager.from_queryset( # type: ignore[misc] - AbstractModel.QuerySet + WarehouseModel.QuerySet )() # type: ignore[assignment] session: "_session.Session"