diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py new file mode 100644 index 0000000000000000000000000000000000000000..6b791a5d1971161afcae59b6e59ec00414a435b6 --- /dev/null +++ b/aleksis/core/managers.py @@ -0,0 +1,81 @@ +from datetime import date +from typing import Union + +from django.contrib.sites.managers import CurrentSiteManager as _CurrentSiteManager +from django.db.models import QuerySet + +from calendarweek import CalendarWeek + + +class CurrentSiteManagerWithoutMigrations(_CurrentSiteManager): + """CurrentSiteManager for auto-generating managers just by query sets.""" + + use_in_migrations = False + + +class DateRangeQuerySetMixin: + """QuerySet with custom query methods for models with date ranges. + + Filterable fields: date_start, date_end + """ + + def within_dates(self, start: date, end: date): + """Filter for all objects within a date range.""" + return self.filter(date_start__lte=end, date_end__gte=start) + + def in_week(self, wanted_week: CalendarWeek): + """Filter for all objects within a calendar week.""" + return self.within_dates(wanted_week[0], wanted_week[6]) + + def on_day(self, day: date): + """Filter for all objects on a certain day.""" + return self.within_dates(day, day) + + +class SchoolYearQuerySet(QuerySet, DateRangeQuerySetMixin): + """Custom query set for school years.""" + + +class SchoolYearRelatedQuerySet(QuerySet): + """Custom query set for all models related to school years.""" + + def within_dates(self, start: date, end: date) -> "SchoolYearRelatedQuerySet": + """Filter for all objects within a date range.""" + return self.filter(school_year__date_start__lte=end, school_year__date_end__gte=start) + + def in_week(self, wanted_week: CalendarWeek) -> "SchoolYearRelatedQuerySet": + """Filter for all objects within a calendar week.""" + return self.within_dates(wanted_week[0], wanted_week[6]) + + def on_day(self, day: date) -> "SchoolYearRelatedQuerySet": + """Filter for all objects on a certain day.""" + return self.within_dates(day, day) + + def for_school_year(self, school_year: "SchoolYear") -> "SchoolYearRelatedQuerySet": + return self.filter(school_year=school_year) + + def for_current_school_year_or_all(self) -> "SchoolYearRelatedQuerySet": + """Get all objects related to current school year. + + If there is no current school year, it will return all objects. + """ + from aleksis.core.models import SchoolYear + + current_school_year = SchoolYear.current + if current_school_year: + return self.for_school_year(current_school_year) + else: + return self + + def for_current_school_year_or_none(self) -> Union["SchoolYearRelatedQuerySet", None]: + """Get all objects related to current school year. + + If there is no current school year, it will return `None`. + """ + from aleksis.core.models import SchoolYear + + current_school_year = SchoolYear.current + if current_school_year: + return self.for_school_year(current_school_year) + else: + return None diff --git a/aleksis/core/migrations/0002_school_year.py b/aleksis/core/migrations/0002_school_year.py new file mode 100644 index 0000000000000000000000000000000000000000..ec66b7ab4ee9a34d382095e6b891113b95942f18 --- /dev/null +++ b/aleksis/core/migrations/0002_school_year.py @@ -0,0 +1,31 @@ +# Generated by Django 3.0.6 on 2020-06-04 09:50 + +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('core', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='SchoolYear', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', django.contrib.postgres.fields.jsonb.JSONField(default=dict, editable=False)), + ('name', models.CharField(max_length=255, unique=True, verbose_name='Name')), + ('date_start', models.DateField(verbose_name='Start date')), + ('date_end', models.DateField(verbose_name='End date')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.Site')), + ], + options={ + 'verbose_name': 'School year', + 'verbose_name_plural': 'School years', + }, + ), + ] diff --git a/aleksis/core/models.py b/aleksis/core/models.py index b5bb204c25139c463e6d53c27b7c1ddf51b75576..5b0cbf720839240daad4edb2b708c734d8d45a0c 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -8,11 +8,13 @@ from django.contrib.auth.models import Group as DjangoGroup from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site +from django.core.exceptions import ValidationError from django.db import models from django.db.models import QuerySet from django.forms.widgets import Media from django.urls import reverse from django.utils import timezone +from django.utils.decorators import classproperty from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -22,7 +24,8 @@ from image_cropping import ImageCropField, ImageRatioField from phonenumber_field.modelfields import PhoneNumberField from polymorphic.models import PolymorphicModel -from .mixins import ExtensibleModel, PureDjangoModel +from .managers import CurrentSiteManagerWithoutMigrations, SchoolYearQuerySet +from .mixins import ExtensibleModel, PureDjangoModel, SchoolYearRelatedExtensibleModel from .tasks import send_notification from .util.core_helpers import get_site_preferences, now_tomorrow from .util.model_helpers import ICONS @@ -43,6 +46,53 @@ FIELD_CHOICES = ( ) +class SchoolYear(ExtensibleModel): + """School year model. + + This is used to manage start and end times of a school year and link data to it. + """ + + objects = CurrentSiteManagerWithoutMigrations.from_queryset(SchoolYearQuerySet)() + + name = models.CharField(verbose_name=_("Name"), max_length=255, unique=True) + + date_start = models.DateField(verbose_name=_("Start date")) + date_end = models.DateField(verbose_name=_("End date")) + + @classmethod + def get_current(cls, day: Optional[date] = None): + if not day: + day = timezone.now().date() + try: + return cls.objects.on_day(day).first() + except SchoolYear.DoesNotExist: + return None + + @classproperty + def current(cls): + return cls.get_current() + + def clean(self): + """Ensure there is only one school year of each point of time.""" + if self.date_end < self.date_start: + raise ValidationError(_("The start date must be earlier than the end date.")) + + qs = SchoolYear.objects.within_dates(self.date_start, self.date_end) + if self.pk: + qs.exclude(pk=self.pk) + if qs.exists(): + raise ValidationError( + _("There is already a school year for this time or a part of this time.") + ) + + def __str__(self): + return self.name + + class Meta: + verbose_name = _("School year") + verbose_name_plural = _("School years") + + class Person(ExtensibleModel): """Person model. @@ -272,7 +322,7 @@ class Group(ExtensibleModel): name = models.CharField(verbose_name=_("Long name"), max_length=255, unique=True) short_name = models.CharField( - verbose_name=_("Short name"), max_length=255, unique=True, blank=True, null=True # noqa + verbose_name=_("Short name"), max_length=255, blank=True, null=True # noqa ) members = models.ManyToManyField(