diff --git a/.gitignore b/.gitignore index fd1f3e48..0d634a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ venv/ .pytest_cache/ **__pycache__/ *.pyc -app/db.sqlite3 \ No newline at end of file +app/db.sqlite3 +db.sqlite3 \ No newline at end of file diff --git a/cinema/migrations/0003_movie_duration.py b/cinema/migrations/0003_movie_duration.py index 7355c91a..e75f6794 100644 --- a/cinema/migrations/0003_movie_duration.py +++ b/cinema/migrations/0003_movie_duration.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('cinema', '0002_initial'), + ("cinema", "0002_initial"), ] operations = [ migrations.AddField( - model_name='movie', - name='duration', + model_name="movie", + name="duration", field=models.IntegerField(default=123), preserve_default=False, ), diff --git a/cinema/migrations/0004_alter_genre_name.py b/cinema/migrations/0004_alter_genre_name.py index 83f65fd1..eb3d0ffb 100644 --- a/cinema/migrations/0004_alter_genre_name.py +++ b/cinema/migrations/0004_alter_genre_name.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('cinema', '0003_movie_duration'), + ("cinema", "0003_movie_duration"), ] operations = [ migrations.AlterField( - model_name='genre', - name='name', + model_name="genre", + name="name", field=models.CharField(max_length=255, unique=True), ), ] diff --git a/cinema/models.py b/cinema/models.py index f18f166c..73ba83d2 100644 --- a/cinema/models.py +++ b/cinema/models.py @@ -64,7 +64,8 @@ def __str__(self): class Order(models.Model): created_at = models.DateTimeField(auto_now_add=True) user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE ) def __str__(self): @@ -79,21 +80,27 @@ class Ticket(models.Model): MovieSession, on_delete=models.CASCADE, related_name="tickets" ) order = models.ForeignKey( - Order, on_delete=models.CASCADE, related_name="tickets" + Order, + on_delete=models.CASCADE, + related_name="tickets" ) row = models.IntegerField() seat = models.IntegerField() - def clean(self): + @staticmethod + def validate_ticket( + row: int, seat: int, movie_session: MovieSession, error_to_raise + ): for ticket_attr_value, ticket_attr_name, cinema_hall_attr_name in [ - (self.row, "row", "rows"), - (self.seat, "seat", "seats_in_row"), + (row, "row", "rows"), + (seat, "seat", "seats_in_row"), ]: count_attrs = getattr( - self.movie_session.cinema_hall, cinema_hall_attr_name + movie_session.cinema_hall, + cinema_hall_attr_name ) if not (1 <= ticket_attr_value <= count_attrs): - raise ValidationError( + raise error_to_raise( { ticket_attr_name: f"{ticket_attr_name} " f"number must be in available range: " @@ -102,6 +109,14 @@ def clean(self): } ) + def clean(self): + Ticket.validate_ticket( + self.row, + self.seat, + self.movie_session, + ValidationError + ) + def save( self, force_insert=False, @@ -111,7 +126,10 @@ def save( ): self.full_clean() super(Ticket, self).save( - force_insert, force_update, using, update_fields + force_insert, + force_update, + using, + update_fields ) def __str__(self): diff --git a/cinema/pagination.py b/cinema/pagination.py new file mode 100644 index 00000000..e9de3785 --- /dev/null +++ b/cinema/pagination.py @@ -0,0 +1,5 @@ +from rest_framework.pagination import LimitOffsetPagination + + +class OrderPagination(LimitOffsetPagination): + default_limit = 2 diff --git a/cinema/serializers.py b/cinema/serializers.py index a1a4d7d4..6a2891d5 100644 --- a/cinema/serializers.py +++ b/cinema/serializers.py @@ -1,6 +1,15 @@ +from django.db import transaction from rest_framework import serializers -from cinema.models import Genre, Actor, CinemaHall, Movie, MovieSession +from cinema.models import ( + Genre, + Actor, + CinemaHall, + Movie, + MovieSession, + Order, + Ticket, +) class GenreSerializer(serializers.ModelSerializer): @@ -29,7 +38,9 @@ class Meta: class MovieListSerializer(MovieSerializer): genres = serializers.SlugRelatedField( - many=True, read_only=True, slug_field="name" + many=True, + read_only=True, + slug_field="name" ) actors = serializers.SlugRelatedField( many=True, read_only=True, slug_field="full_name" @@ -54,11 +65,13 @@ class Meta: class MovieSessionListSerializer(MovieSessionSerializer): movie_title = serializers.CharField(source="movie.title", read_only=True) cinema_hall_name = serializers.CharField( - source="cinema_hall.name", read_only=True + source="cinema_hall.name", + read_only=True ) cinema_hall_capacity = serializers.IntegerField( source="cinema_hall.capacity", read_only=True ) + tickets_available = serializers.IntegerField(read_only=True) class Meta: model = MovieSession @@ -68,13 +81,62 @@ class Meta: "movie_title", "cinema_hall_name", "cinema_hall_capacity", + "tickets_available", ) +class MovieSessionTicketSerializer(serializers.ModelSerializer): + class Meta: + model = Ticket + fields = ("row", "seat") + + class MovieSessionDetailSerializer(MovieSessionSerializer): - movie = MovieListSerializer(many=False, read_only=True) - cinema_hall = CinemaHallSerializer(many=False, read_only=True) + movie = MovieListSerializer(read_only=True) + cinema_hall = CinemaHallSerializer(read_only=True) + taken_places = MovieSessionTicketSerializer( + source="tickets", many=True, read_only=True + ) class Meta: model = MovieSession - fields = ("id", "show_time", "movie", "cinema_hall") + fields = ("id", "show_time", "movie", "cinema_hall", "taken_places") + + +class TicketSerializer(serializers.ModelSerializer): + def validate(self, attrs): + data = super().validate(attrs) + Ticket.validate_ticket( + attrs["row"], + attrs["seat"], + attrs["movie_session"], + serializers.ValidationError, + ) + return data + + class Meta: + model = Ticket + fields = ("id", "row", "seat", "movie_session") + + +class TicketListSerializer(TicketSerializer): + movie_session = MovieSessionListSerializer(many=False, read_only=False) + + +class OrderListSerializer(serializers.ModelSerializer): + tickets = TicketListSerializer(many=True, read_only=True) + + class Meta: + model = Order + fields = ("id", "tickets", "created_at") + + def create(self, validated_data): + with transaction.atomic(): + tickets_data = validated_data.pop("tickets") + order = Order.objects.create(**validated_data) + for ticket_data in tickets_data: + Ticket.objects.create(order=order, **ticket_data) + + +class OrderCreateSerializer(OrderListSerializer): + tickets = TicketSerializer(many=True, read_only=False, allow_empty=False) diff --git a/cinema/tests/test_actor_api.py b/cinema/tests/test_actor_api.py index 3fa7a469..bda27ba7 100644 --- a/cinema/tests/test_actor_api.py +++ b/cinema/tests/test_actor_api.py @@ -70,4 +70,4 @@ def test_delete_invalid_actor(self): response = self.client.delete( "/api/cinema/actors/1000/", ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/cinema/tests/test_cinema_hall_api.py b/cinema/tests/test_cinema_hall_api.py index d3e9ea77..fe5b62f1 100644 --- a/cinema/tests/test_cinema_hall_api.py +++ b/cinema/tests/test_cinema_hall_api.py @@ -127,4 +127,4 @@ def test_delete_invalid_cinema_hall(self): response = self.client.delete( "/api/cinema/cinema_halls/1000/", ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/cinema/tests/test_genre_api.py b/cinema/tests/test_genre_api.py index a81daaf4..4da47eb1 100644 --- a/cinema/tests/test_genre_api.py +++ b/cinema/tests/test_genre_api.py @@ -59,4 +59,4 @@ def test_delete_invalid_genre(self): response = self.client.delete( "/api/cinema/genres/1000/", ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/cinema/tests/test_movie_api.py b/cinema/tests/test_movie_api.py index 958c09a1..3b4a2cf5 100644 --- a/cinema/tests/test_movie_api.py +++ b/cinema/tests/test_movie_api.py @@ -152,4 +152,4 @@ def test_delete_invalid_movie(self): response = self.client.delete( "/api/cinema/movies/1000/", ) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/cinema/tests/test_movie_session_api.py b/cinema/tests/test_movie_session_api.py index 9b75d7ed..f86fb1e0 100644 --- a/cinema/tests/test_movie_session_api.py +++ b/cinema/tests/test_movie_session_api.py @@ -126,4 +126,4 @@ def test_get_movie_session(self): self.assertEqual(response.data["cinema_hall"]["capacity"], 140) self.assertEqual(response.data["cinema_hall"]["rows"], 10) self.assertEqual(response.data["cinema_hall"]["seats_in_row"], 14) - self.assertEqual(response.data["cinema_hall"]["name"], "White") + self.assertEqual(response.data["cinema_hall"]["name"], "White") \ No newline at end of file diff --git a/cinema/tests/test_order_api.py b/cinema/tests/test_order_api.py index dd15df53..88f31950 100644 --- a/cinema/tests/test_order_api.py +++ b/cinema/tests/test_order_api.py @@ -86,4 +86,4 @@ def test_movie_session_list_tickets_available(self): self.assertEqual( response.data[0]["tickets_available"], self.cinema_hall.capacity - 1, - ) + ) \ No newline at end of file diff --git a/cinema/urls.py b/cinema/urls.py index e3586f00..5ad6fb5b 100644 --- a/cinema/urls.py +++ b/cinema/urls.py @@ -7,6 +7,7 @@ CinemaHallViewSet, MovieViewSet, MovieSessionViewSet, + OrderViewSet, ) router = routers.DefaultRouter() @@ -15,6 +16,7 @@ router.register("cinema_halls", CinemaHallViewSet) router.register("movies", MovieViewSet) router.register("movie_sessions", MovieSessionViewSet) +router.register("orders", OrderViewSet) urlpatterns = [path("", include(router.urls))] diff --git a/cinema/views.py b/cinema/views.py index c4ff85e9..2beebf91 100644 --- a/cinema/views.py +++ b/cinema/views.py @@ -1,6 +1,8 @@ +from django.db.models import F, Count from rest_framework import viewsets -from cinema.models import Genre, Actor, CinemaHall, Movie, MovieSession +from cinema.models import Genre, Actor, CinemaHall, Movie, MovieSession, Order +from cinema.pagination import OrderPagination from cinema.serializers import ( GenreSerializer, @@ -12,6 +14,8 @@ MovieDetailSerializer, MovieSessionDetailSerializer, MovieListSerializer, + OrderListSerializer, + OrderCreateSerializer, ) @@ -31,9 +35,37 @@ class CinemaHallViewSet(viewsets.ModelViewSet): class MovieViewSet(viewsets.ModelViewSet): - queryset = Movie.objects.all() + queryset = Movie.objects.prefetch_related("actors", "genres") serializer_class = MovieSerializer + @staticmethod + def _params_to_int(item_string: str) -> list[int]: + return [ + int(item_id.strip(" ")) + for item_id in item_string.split(",") + ] + + def get_queryset(self): + queryset = super().get_queryset() + + if self.action == "list": + title = self.request.query_params.get("title") + actors = self.request.query_params.get("actors") + genres = self.request.query_params.get("genres") + + if title: + queryset = queryset.filter(title__icontains=title) + if actors: + queryset = queryset.filter( + actors__id__in=self._params_to_int(actors) + ) + if genres: + queryset = queryset.filter( + genres__id__in=self._params_to_int(genres) + ) + + return queryset.distinct() + def get_serializer_class(self): if self.action == "list": return MovieListSerializer @@ -48,6 +80,32 @@ class MovieSessionViewSet(viewsets.ModelViewSet): queryset = MovieSession.objects.all() serializer_class = MovieSessionSerializer + def get_queryset(self): + queryset = super().get_queryset() + + if self.action == "list": + date = self.request.query_params.get("date") + + movie_id = self.request.query_params.get("movie") + + if date: + queryset = queryset.filter(show_time__date=date) + if movie_id: + queryset = queryset.filter(movie__id=int(movie_id)) + + queryset = ( + queryset.select_related("movie", "cinema_hall") + .annotate( + tickets_available=F("cinema_hall__rows") + * F("cinema_hall__seats_in_row") + - Count("tickets") + ) + ) + else: + queryset = queryset.select_related("movie", "cinema_hall") + + return queryset + def get_serializer_class(self): if self.action == "list": return MovieSessionListSerializer @@ -56,3 +114,28 @@ def get_serializer_class(self): return MovieSessionDetailSerializer return MovieSessionSerializer + + +class OrderViewSet(viewsets.ModelViewSet): + queryset = Order.objects.all() + serializer_class = OrderListSerializer + pagination_class = OrderPagination + + def get_queryset(self): + queryset = super().get_queryset() + if self.action == "list": + queryset = queryset.prefetch_related( + "tickets__movie_session__cinema_hall", + "tickets__movie_session__movie" + ) + + return queryset.filter(user=self.request.user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def get_serializer_class(self): + if self.action == "create": + return OrderCreateSerializer + + return OrderListSerializer diff --git a/cinema_service/settings.py b/cinema_service/settings.py index a7d6c992..a3538474 100644 --- a/cinema_service/settings.py +++ b/cinema_service/settings.py @@ -20,9 +20,7 @@ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = ( - "django-insecure-6vubhk2$++agnctay_4pxy_8cq)mosmn(*-#2b^v4cgsh-^!i3" -) +SECRET_KEY = "django-insecure-6vubhk2$++agnctay_4pxy_8cq)mosmn(*-#2b^v4cgsh-^!" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -124,7 +122,7 @@ USE_I18N = True -USE_TZ = False +USE_TZ = True # Static files (CSS, JavaScript, Images) @@ -136,3 +134,7 @@ # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +REST_FRAMEWORK = { + "PAGE_SIZE": 3 +}