diff --git a/program/models.py b/program/models.py index 0b634d0c51b848ebe319dd0113d4de35909c66a8..ac7677b65c830e680e80c91c0dc23277d8962d4d 100644 --- a/program/models.py +++ b/program/models.py @@ -41,6 +41,12 @@ from steering.settings import ( ) +class ScheduleConflictError(ValidationError): + def __init__(self, *args, conflicts=None, **kwargs): + super().__init__(*args, **kwargs) + self.conflicts = conflicts + + class Type(models.Model): name = models.CharField(max_length=32) slug = models.SlugField(max_length=32, unique=True) @@ -323,25 +329,25 @@ class Schedule(models.Model): True if sdl.get("add_business_days_only") is True else False ) - # TODO: replace `dstart` with `first_date` when the dashboard is updated - first_date = parse_date(str(sdl["dstart"])) - # TODO: replace `tstart` with `start_time` when the dashboard is updated + first_date = parse_date(str(sdl["first_date"])) start_time = ( - sdl["tstart"] + ":00" if len(str(sdl["tstart"])) == 5 else sdl["tstart"] + sdl["start_time"] + ":00" + if len(str(sdl["start_time"])) == 5 + else sdl["start_time"] + ) + end_time = ( + sdl["end_time"] + ":00" + if len(str(sdl["end_time"])) == 5 + else sdl["end_time"] ) - # TODO: replace `tend` with `end_time` when the dashboard is updated - end_time = sdl["tend"] + ":00" if len(str(sdl["tend"])) == 5 else sdl["tend"] start_time = parse_time(str(start_time)) end_time = parse_time(str(end_time)) - # TODO: replace `until` with `last_date` when the dashboard is updated - if sdl["until"]: - last_date = parse_date(str(sdl["until"])) + if sdl["last_date"]: + last_date = parse_date(str(sdl["last_date"])) else: - # If no until date was set - # Set it to the end of the year - # Or add x days + # If last_date was not set, set it to the end of the year or add x days if AUTO_SET_UNTIL_DATE_TO_END_OF_YEAR: year = timezone.now().year last_date = parse_date(f"{year}-12-31") @@ -350,10 +356,9 @@ class Schedule(models.Model): days=+AUTO_SET_UNTIL_DATE_TO_DAYS_IN_FUTURE ) - # TODO: replace `byweekday` with `by_weekday` when the dashboard is updated schedule = Schedule( pk=pk, - by_weekday=sdl["byweekday"], + by_weekday=sdl["by_weekday"], rrule=rrule, first_date=first_date, start_time=start_time, @@ -373,7 +378,7 @@ class Schedule(models.Model): def generate_timeslots(schedule): """ Returns a list of timeslot objects based on a schedule and its rrule - Returns past timeslots as well, starting from dstart (not today) + Returns past timeslots as well, starting from first_date (not today) """ by_week_no = None @@ -696,7 +701,7 @@ class Schedule(models.Model): # Generate schedule to be saved schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk) - # Copy if dstart changes for generating timeslots + # Copy if first_date changes for generating timeslots gen_schedule = schedule # Generate timeslots @@ -733,7 +738,7 @@ class Schedule(models.Model): """ sdl = data["schedule"] - solutions = data["solutions"] + solutions = data.get("solutions", []) # Regenerate conflicts schedule = Schedule.instantiate_upcoming(sdl, show_pk, schedule_pk) @@ -742,13 +747,13 @@ class Schedule(models.Model): if schedule.rrule.freq > 0 and schedule.first_date == schedule.last_date: raise ValidationError( - _("Start and until dates mustn't be the same"), + _("Start and end dates must not be the same."), code="no-same-day-start-and-end", ) if schedule.last_date < schedule.first_date: raise ValidationError( - _("Until date mustn't be before start"), + _("End date mustn't be before start."), code="no-start-after-end", ) @@ -757,9 +762,10 @@ class Schedule(models.Model): ) if len(solutions) != num_conflicts: - raise ValidationError( + raise ScheduleConflictError( _("Numbers of conflicts and solutions don't match."), code="one-solution-per-conflict", + conflicts=conflicts, ) # Projected timeslots to create @@ -964,7 +970,11 @@ class Schedule(models.Model): conflicts["notes"] = data.get("notes") conflicts["playlists"] = data.get("playlists") - return conflicts + raise ScheduleConflictError( + _("Not all conflicts have been resolved."), + code="unresolved-conflicts", + conflicts=conflicts, + ) # Collect upcoming timeslots to delete which might still remain del_timeslots = TimeSlot.objects.filter( diff --git a/program/serializers.py b/program/serializers.py index ee5488be3cac05a12c20d6893e753555247b2a37..3ef7853fe0607197e3d206588af14f90e83b9436 100644 --- a/program/serializers.py +++ b/program/serializers.py @@ -36,7 +36,6 @@ from program.models import ( MusicFocus, Note, NoteLink, - RRule, Schedule, Show, TimeSlot, @@ -405,35 +404,16 @@ class ShowSerializer(serializers.HyperlinkedModelSerializer): class ScheduleSerializer(serializers.ModelSerializer): - rrule = serializers.PrimaryKeyRelatedField( - queryset=RRule.objects.all(), - help_text=Schedule.rrule.field.help_text, - ) - show = serializers.PrimaryKeyRelatedField( - queryset=Show.objects.all(), - help_text=Schedule.show.field.help_text, - ) - # TODO: remove this when the dashboard is updated - byweekday = serializers.IntegerField( - source="by_weekday", - help_text=Schedule.by_weekday.field.help_text, - ) - dstart = serializers.DateField( - source="first_date", - help_text=Schedule.first_date.field.help_text, - ) - tstart = serializers.TimeField( - source="start_time", - help_text=Schedule.start_time.field.help_text, - ) - tend = serializers.TimeField( - source="end_time", - help_text=Schedule.end_time.field.help_text, - ) - until = serializers.DateField( - source="last_date", - help_text=Schedule.last_date.field.help_text, - ) + class Meta: + model = Schedule + fields = "__all__" + + +class UnsavedScheduleSerializer(ScheduleSerializer): + id = serializers.IntegerField(allow_null=True) + + +class ScheduleInRequestSerializer(ScheduleSerializer): dryrun = serializers.BooleanField( write_only=True, required=False, @@ -464,11 +444,11 @@ class ScheduleSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): """Update and return an existing Schedule instance, given the validated data.""" - instance.by_weekday = validated_data.get("byweekday", instance.by_weekday) - instance.first_date = validated_data.get("dstart", instance.first_date) - instance.start_time = validated_data.get("tstart", instance.start_time) - instance.end_time = validated_data.get("tend", instance.end_time) - instance.last_date = validated_data.get("until", instance.last_date) + instance.by_weekday = validated_data.get("by_weekday", instance.by_weekday) + instance.first_date = validated_data.get("first_date", instance.first_date) + instance.start_time = validated_data.get("start_time", instance.start_time) + instance.end_time = validated_data.get("end_time", instance.end_time) + instance.last_date = validated_data.get("last_date", instance.last_date) instance.is_repetition = validated_data.get( "is_repetition", instance.is_repetition ) @@ -525,41 +505,24 @@ class DryRunTimeSlotSerializer(serializers.Serializer): class ScheduleCreateUpdateRequestSerializer(serializers.Serializer): - schedule = ScheduleSerializer() - solutions = serializers.DictField(child=serializers.ChoiceField(SOLUTION_CHOICES)) + schedule = ScheduleInRequestSerializer() + solutions = serializers.DictField( + child=serializers.ChoiceField(SOLUTION_CHOICES), required=False + ) notes = serializers.DictField(child=serializers.IntegerField(), required=False) playlists = serializers.DictField(child=serializers.IntegerField(), required=False) -# TODO: There shouldn’t be a separate ScheduleSerializer for use in responses. -# Instead the default serializer should be used. Unfortunately, the -# code that generates the data creates custom dicts with this particular format. -class ScheduleInResponseSerializer(serializers.Serializer): - # "Schedule schema type" is the rendered name of the ScheduleSerializer. - """ - For documentation on the individual fields see the - Schedule schema type. - """ - add_business_days_only = serializers.BooleanField() - add_days_no = serializers.IntegerField(allow_null=True) - by_weekday = serializers.IntegerField() - default_playlist_id = serializers.IntegerField(allow_null=True) - end_time = serializers.TimeField() - first_date = serializers.DateField() - id = serializers.PrimaryKeyRelatedField(queryset=Schedule.objects.all()) - is_repetition = serializers.BooleanField() - last_date = serializers.DateField() - rrule = serializers.PrimaryKeyRelatedField(queryset=RRule.objects.all()) - show = serializers.PrimaryKeyRelatedField(queryset=Note.objects.all()) - start_time = serializers.TimeField() - - -class ScheduleConflictResponseSerializer(serializers.Serializer): +class ScheduleResponseSerializer(serializers.Serializer): projected = ProjectedTimeSlotSerializer(many=True) solutions = serializers.DictField(child=serializers.ChoiceField(SOLUTION_CHOICES)) notes = serializers.DictField(child=serializers.IntegerField()) playlists = serializers.DictField(child=serializers.IntegerField()) - schedule = ScheduleInResponseSerializer() + schedule = ScheduleSerializer() + + +class ScheduleConflictResponseSerializer(ScheduleResponseSerializer): + schedule = UnsavedScheduleSerializer() class ScheduleDryRunResponseSerializer(serializers.Serializer): diff --git a/program/views.py b/program/views.py index e0f7b4d893f9c8d1ec21198f1b0b2f60441df75a..acd66207de9ee0a73d6ae0430b9536917adbf7ff 100644 --- a/program/views.py +++ b/program/views.py @@ -42,6 +42,7 @@ from program.models import ( MusicFocus, Note, Schedule, + ScheduleConflictError, Show, TimeSlot, Topic, @@ -58,6 +59,7 @@ from program.serializers import ( ScheduleConflictResponseSerializer, ScheduleCreateUpdateRequestSerializer, ScheduleDryRunResponseSerializer, + ScheduleResponseSerializer, ScheduleSerializer, ShowSerializer, TimeSlotSerializer, @@ -344,9 +346,10 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): @extend_schema_view( create=extend_schema( summary="Create a new schedule.", + request=ScheduleCreateUpdateRequestSerializer, responses={ status.HTTP_201_CREATED: OpenApiResponse( - response=ScheduleConflictResponseSerializer, + response=ScheduleResponseSerializer, description=( "Signals the successful creation of the schedule and of the projected " "timeslots." @@ -366,9 +369,9 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): Returned in case the request contained invalid data. This may happen if: - * the until date is before the start date (`no-start-after-end`), + * the last date is before the start date (`no-start-after-end`), in which case you should correct either the start or until date. - * The start and until date are the same (`no-same-day-start-and-end`). + * The start and last date are the same (`no-same-day-start-and-end`). This is only allowed for single timeslots with the recurrence rule set to `once`. You should fix either the start or until date. * The number of conflicts and solutions aren’t the same @@ -410,8 +413,14 @@ class APIShowViewSet(DisabledObjectPermissionCheckMixin, viewsets.ModelViewSet): }, ), retrieve=extend_schema(summary="Retrieve a single schedule."), - update=extend_schema(summary="Update an existing schedule."), - partial_update=extend_schema(summary="Partially update an existing schedule."), + update=extend_schema( + summary="Update an existing schedule.", + request=ScheduleCreateUpdateRequestSerializer, + ), + partial_update=extend_schema( + summary="Partially update an existing schedule.", + request=ScheduleCreateUpdateRequestSerializer, + ), destroy=extend_schema(summary="Delete an existing schedule."), list=extend_schema(summary="List all schedules."), ) @@ -475,28 +484,16 @@ class APIScheduleViewSet( if show_pk is None or "schedule" not in request.data: return Response(status=status.HTTP_400_BAD_REQUEST) - # First create submit -> return projected timeslots and collisions - # TODO: Perhaps directly insert into database if no conflicts found - if "solutions" not in request.data: - # TODO: respond with status.HTTP_409_CONFLICT when the dashboard can handle it - return Response( - Schedule.make_conflicts(request.data["schedule"], pk, show_pk), - ) - - # Otherwise try to resolve - resolution = Schedule.resolve_conflicts(request.data, pk, show_pk) + try: + resolution = Schedule.resolve_conflicts(request.data, pk, show_pk) + except ScheduleConflictError as exc: + return Response(exc.conflicts, status.HTTP_409_CONFLICT) if all(key in resolution for key in ["create", "update", "delete"]): # this is a dry-run return Response(resolution, status=status.HTTP_202_ACCEPTED) - # If resolution went well - if "projected" not in resolution: - return Response(resolution, status=status.HTTP_201_CREATED) - - # Otherwise return conflicts - # TODO: respond with status.HTTP_409_CONFLICT when the dashboard can handle it - return Response(resolution) + return Response(resolution, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): """ @@ -532,26 +529,13 @@ class APIScheduleViewSet( serializer = ScheduleSerializer(schedule) return Response(serializer.data) - # First update submit -> return projected timeslots and collisions - if "solutions" not in request.data: - # TODO: respond with status.HTTP_409_CONFLICT when the dashboard can handle it - return Response( - Schedule.make_conflicts( - request.data["schedule"], schedule.pk, schedule.show.pk - ) + try: + resolution = Schedule.resolve_conflicts( + request.data, schedule.pk, schedule.show.pk ) + except ScheduleConflictError as exc: + return Response(exc.conflicts, status.HTTP_409_CONFLICT) - # Otherwise try to resolve - resolution = Schedule.resolve_conflicts( - request.data, schedule.pk, schedule.show.pk - ) - - # If resolution went well - if "projected" not in resolution: - return Response(resolution) - - # Otherwise return conflicts - # TODO: respond with status.HTTP_409_CONFLICT when the dashboard can handle it return Response(resolution) def destroy(self, request, *args, **kwargs):