├── backend
├── cinema
│ ├── __init__.py
│ ├── tests
│ │ ├── __init__.py
│ │ └── test_movie_api.py
│ ├── management
│ │ ├── __init__.py
│ │ └── commands
│ │ │ ├── __init__.py
│ │ │ └── wait_for_db.py
│ ├── migrations
│ │ ├── __init__.py
│ │ └── 0001_initial.py
│ ├── apps.py
│ ├── admin.py
│ ├── permissions.py
│ ├── urls.py
│ ├── models.py
│ ├── serializers.py
│ └── views.py
├── user
│ ├── __init__.py
│ ├── migrations
│ │ ├── __init__.py
│ │ ├── 0002_alter_user_managers_remove_user_username_and_more.py
│ │ └── 0001_initial.py
│ ├── apps.py
│ ├── views.py
│ ├── urls.py
│ ├── serializers.py
│ ├── admin.py
│ └── models.py
├── cinema_service
│ ├── __init__.py
│ ├── asgi.py
│ ├── wsgi.py
│ ├── urls.py
│ └── settings.py
├── Dockerfile
└── manage.py
├── .dockerignore
├── frontend
├── .env.sample
├── api
│ ├── user
│ │ ├── me
│ │ ├── register
│ │ └── token
│ └── cinema
│ │ ├── genres
│ │ ├── movies-1
│ │ ├── actors
│ │ ├── movie_sessions
│ │ ├── movie_sessions-1
│ │ ├── movies
│ │ └── orders
├── public
│ ├── favicon.ico
│ └── assets
│ │ └── icons
│ │ ├── cinema_favicon.png
│ │ ├── check.svg
│ │ ├── arrow.svg
│ │ ├── plus.svg
│ │ ├── cross.svg
│ │ ├── left_arrow.svg
│ │ ├── right_arrow.svg
│ │ ├── detail.svg
│ │ ├── calendar.svg
│ │ ├── success.svg
│ │ ├── error.svg
│ │ ├── clock.svg
│ │ ├── eye_crossed.svg
│ │ ├── eye.svg
│ │ └── cart.svg
├── src
│ ├── main.js
│ ├── views
│ │ ├── AppFooter.vue
│ │ ├── MovieDetailsScreen.vue
│ │ ├── SignIn.vue
│ │ ├── SignUp.vue
│ │ ├── CinemaHallListScreen.vue
│ │ ├── CinemaHallAddScreen.vue
│ │ ├── AppHeader.vue
│ │ ├── ProfileScreen.vue
│ │ ├── GenreListScreen.vue
│ │ ├── MovieSessionListScreen.vue
│ │ ├── ActorListScreen.vue
│ │ ├── MovieSessionDetailsScreen.vue
│ │ ├── MovieSessionAddScreen.vue
│ │ ├── MovieListScreen.vue
│ │ ├── OrderListScreen.vue
│ │ └── MovieAddScreen.vue
│ ├── comps
│ │ ├── HeaderPopup.vue
│ │ ├── AddBtn.vue
│ │ ├── CheckboxItem.vue
│ │ ├── ImageUploader.vue
│ │ ├── ActionButton.vue
│ │ ├── TimePicker.vue
│ │ ├── DatePicker.vue
│ │ ├── InputItem.vue
│ │ ├── MovieModal.vue
│ │ ├── PasswordInput.vue
│ │ ├── MovieCard.vue
│ │ ├── CustomSelect.vue
│ │ ├── CustomMultiselect.vue
│ │ └── CinemaHallSchema.vue
│ ├── assets
│ │ └── main.css
│ └── App.vue
├── .eslintrc.json
├── .gitignore
├── vite.config.js
├── README.md
├── index.html
└── package.json
├── .env.sample
├── .gitignore
├── .flake8
├── requirements.txt
├── README.md
├── docker-compose.yml
└── sample_cinema.json
/backend/cinema/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/user/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/cinema/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/cinema/management/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/cinema/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/cinema_service/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/user/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | venv/
2 | python-version
3 | .env
--------------------------------------------------------------------------------
/backend/cinema/management/commands/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/.env.sample:
--------------------------------------------------------------------------------
1 | VITE_API_URL=http://127.0.0.1:5173
--------------------------------------------------------------------------------
/frontend/api/user/me:
--------------------------------------------------------------------------------
1 | {
2 | "id": 2,
3 | "email": "user@user.com",
4 | "is_staff": false
5 | }
--------------------------------------------------------------------------------
/frontend/api/user/register:
--------------------------------------------------------------------------------
1 | {
2 | "id": 2,
3 | "email": "user@user.com",
4 | "is_staff": false
5 | }
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kapitoshk4/py-cinema-full-stack/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/.env.sample:
--------------------------------------------------------------------------------
1 | POSTGRES_HOST=POSTGRES_HOST
2 | POSTGRES_DB=POSTGRES_DB
3 | POSTGRES_USER=POSTGRES_USER
4 | POSTGRES_PASSWORD=POSTGRES_PASSWORD
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | .vscode/
3 | *.iml
4 | .env
5 | .DS_Store
6 | venv/
7 | .pytest_cache/
8 | **__pycache__/
9 | **db.sqlite3
10 | media
11 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/cinema_favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kapitoshk4/py-cinema-full-stack/HEAD/frontend/public/assets/icons/cinema_favicon.png
--------------------------------------------------------------------------------
/backend/user/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class UserConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "user"
7 |
--------------------------------------------------------------------------------
/backend/cinema/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class CinemaConfig(AppConfig):
5 | default_auto_field = "django.db.models.BigAutoField"
6 | name = "cinema"
7 |
--------------------------------------------------------------------------------
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | inline-quotes = "
3 | ignore = E203, E266, W503, N807, N818, F401
4 | max-line-length = 79
5 | max-complexity = 18
6 | select = B,C,E,F,W,T4,B9,Q0,N8,VNE
7 | exclude =
8 | **migrations
9 | venv
10 | tests
--------------------------------------------------------------------------------
/frontend/public/assets/icons/check.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/plus.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/cross.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | Django==4.0.4
2 | django-debug-toolbar==3.4.0
3 | djangorestframework==3.13.1
4 | djangorestframework-simplejwt==5.2.0
5 | drf-spectacular==0.22.1
6 | Pillow==9.1.1
7 | flake8==5.0.4
8 | flake8-quotes==3.3.1
9 | flake8-variables-names==0.0.5
10 | pep8-naming==0.13.2
11 | psycopg2-binary==2.9.3
--------------------------------------------------------------------------------
/frontend/api/cinema/genres:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "name": "drama"
5 | },
6 | {
7 | "id": 2,
8 | "name": "comedy"
9 | },
10 | {
11 | "id": 3,
12 | "name": "fantasy"
13 | },
14 | {
15 | "id": 4,
16 | "name": "romance"
17 | }
18 | ]
--------------------------------------------------------------------------------
/frontend/src/main.js:
--------------------------------------------------------------------------------
1 | import Vue from 'vue';
2 | import App from './App.vue';
3 | import axios from 'axios';
4 | import VueAxios from 'vue-axios';
5 | import VCalendar from 'v-calendar';
6 |
7 | import './assets/main.css';
8 |
9 | Vue.use(VueAxios, axios);
10 | Vue.use(VCalendar);
11 |
12 | new Vue({
13 | render: (h) => h(App)
14 | }).$mount('#app');
15 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/left_arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/right_arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/detail.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "plugin:vue/essential",
8 | "standard"
9 | ],
10 | "parserOptions": {
11 | "ecmaVersion": 12,
12 | "sourceType": "module"
13 | },
14 | "plugins": [
15 | "vue"
16 | ],
17 | "rules": {
18 | "semi": [2, "always"]
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | .DS_Store
12 | dist
13 | dist-ssr
14 | *.local
15 |
16 | /cypress/videos/
17 | /cypress/screenshots/
18 |
19 | # Editor directories and files
20 | .vscode
21 | !.vscode/extensions.json
22 | .idea
23 | *.suo
24 | *.ntvs*
25 | *.njsproj
26 | *.sln
27 | *.sw?
28 | .env
--------------------------------------------------------------------------------
/frontend/src/views/AppFooter.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
12 |
23 |
--------------------------------------------------------------------------------
/backend/cinema/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from .models import (
4 | CinemaHall,
5 | Genre,
6 | Actor,
7 | Movie,
8 | MovieSession,
9 | Order,
10 | Ticket,
11 | )
12 |
13 | admin.site.register(CinemaHall)
14 | admin.site.register(Genre)
15 | admin.site.register(Actor)
16 | admin.site.register(Movie)
17 | admin.site.register(MovieSession)
18 | admin.site.register(Order)
19 | admin.site.register(Ticket)
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Cinema Fullstack
2 |
3 | - Read [the guideline](https://github.com/mate-academy/py-task-guideline/blob/main/README.md) before start
4 |
5 | ## Task:
6 |
7 | You already have Backend and Frontend implemented.
8 | You need to connect them together, and make sure all functionality of Cinema Shop works.
9 |
10 | NOTE: Attach screenshots of all pages from the correctly connected frontend. Better to make them with opened developer tool, where will be shown requests to the API.
11 |
--------------------------------------------------------------------------------
/backend/cinema/permissions.py:
--------------------------------------------------------------------------------
1 | from rest_framework.permissions import SAFE_METHODS, BasePermission
2 |
3 |
4 | class IsAdminOrIfAuthenticatedReadOnly(BasePermission):
5 | def has_permission(self, request, view):
6 | return bool(
7 | (
8 | request.method in SAFE_METHODS
9 | and request.user
10 | and request.user.is_authenticated
11 | )
12 | or (request.user and request.user.is_staff)
13 | )
14 |
--------------------------------------------------------------------------------
/backend/cinema_service/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for cinema_service project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cinema_service.settings")
15 |
16 | application = get_asgi_application()
17 |
--------------------------------------------------------------------------------
/backend/cinema_service/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for cinema_service project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cinema_service.settings")
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/frontend/api/user/token:
--------------------------------------------------------------------------------
1 | {
2 | "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6NDc5MjM5NjkxNCwianRpIjoiNmM4ODgzZWEwMWVkNGJlZTlhZGE2MTg4YmNkODE2MmYiLCJ1c2VyX2lkIjoyMDQsImVtYWlsIjoiZGFueWxvLnQrNkBtYXRlLmFjYWRlbXkifQ.bpKcYV5LAuWsG_xKbrIJvdcHq48V-uGeyKBX4O0ZQ-0",
3 | "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjo0MjczOTk2OTE0LCJqdGkiOiI4N2E1OTJjN2JlNjg0NzhlYjllZmU5ZTBlMzk2NDMyOCIsInVzZXJfaWQiOjIwNCwiZW1haWwiOiJkYW55bG8udCs2QG1hdGUuYWNhZGVteSJ9.SbB_xBsx-PpfX1sItkbUtN85ZC-IHl03tMT-ZlvZGM0"
4 | }
5 |
--------------------------------------------------------------------------------
/frontend/vite.config.js:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from 'node:url';
2 |
3 | import { defineConfig } from 'vite';
4 | import legacy from '@vitejs/plugin-legacy';
5 | import vue2 from '@vitejs/plugin-vue2';
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [
10 | vue2(),
11 | legacy({
12 | targets: ['ie >= 11'],
13 | additionalLegacyPolyfills: ['regenerator-runtime/runtime']
14 | })
15 | ],
16 | resolve: {
17 | alias: {
18 | '@': fileURLToPath(new URL('./src', import.meta.url))
19 | }
20 | }
21 | });
22 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/calendar.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM --platform=linux/amd64 python:3.10.9-slim-buster
2 | LABEL maintainer="danylo.t@mate.academy"
3 |
4 | WORKDIR /app
5 |
6 |
7 | ENV PYTHONDONTWRITEBYTECODE 1
8 | ENV PYTHONUNBUFFERED 1
9 |
10 |
11 | RUN pip install --upgrade pip
12 | COPY requirements.txt requirements.txt
13 | RUN pip install -r requirements.txt
14 |
15 |
16 | COPY . .
17 |
18 | RUN mkdir -p /vol/web/media
19 |
20 | RUN adduser \
21 | --disabled-password \
22 | --no-create-home \
23 | django-user
24 |
25 | RUN chown -R django-user:django-user /vol/
26 | RUN chmod -R 755 /vol/web/
27 |
28 | USER django-user
--------------------------------------------------------------------------------
/frontend/api/cinema/movies-1:
--------------------------------------------------------------------------------
1 | {
2 | "id": 3,
3 | "title": "Ace Ventura",
4 | "duration": 86,
5 | "description": "A goofy detective specializing in animals goes in search of the missing mascot of the Miami Dolphins.",
6 | "genres": [
7 | {
8 | "id": 2,
9 | "name": "comedy"
10 | }
11 | ],
12 | "actors": [
13 | {
14 | "id": 1,
15 | "first_name": "Jim",
16 | "last_name": "Carrey",
17 | "full_name": "Jim Carrey"
18 | }
19 | ],
20 | "image": "media/uploads/movies/ace-ventura.jpg"
21 | }
--------------------------------------------------------------------------------
/frontend/public/assets/icons/success.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/comps/HeaderPopup.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
13 |
14 |
30 |
--------------------------------------------------------------------------------
/backend/user/views.py:
--------------------------------------------------------------------------------
1 | from rest_framework import generics
2 | from rest_framework.permissions import IsAuthenticated
3 | from rest_framework_simplejwt.authentication import JWTAuthentication
4 |
5 | from user.serializers import UserSerializer
6 |
7 |
8 | class CreateUserView(generics.CreateAPIView):
9 | serializer_class = UserSerializer
10 |
11 |
12 | class ManageUserView(generics.RetrieveUpdateAPIView):
13 | serializer_class = UserSerializer
14 | authentication_classes = (JWTAuthentication,)
15 | permission_classes = (IsAuthenticated,)
16 |
17 | def get_object(self):
18 | return self.request.user
19 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 | app:
5 | build:
6 | context: .
7 | dockerfile: backend/Dockerfile
8 | ports:
9 | - "8080:8080"
10 | volumes:
11 | - ./backend:/app
12 | - ./backend/media:/vol/web/media
13 | command: >
14 | sh -c "python manage.py wait_for_db &&
15 | python manage.py migrate &&
16 | python manage.py runserver 0.0.0.0:8080"
17 | env_file:
18 | - .env
19 | depends_on:
20 | - db
21 |
22 | db:
23 | image: postgres:14-alpine
24 | ports:
25 | - "5432:5432"
26 | env_file:
27 | - .env
--------------------------------------------------------------------------------
/frontend/api/cinema/actors:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "first_name": "Jim",
5 | "last_name": "Carrey",
6 | "full_name": "Jim Carrey"
7 | },
8 | {
9 | "id": 2,
10 | "first_name": "Tom",
11 | "last_name": "Holland",
12 | "full_name": "Tom Holland"
13 | },
14 | {
15 | "id": 3,
16 | "first_name": "Leonardo",
17 | "last_name": "DiCaprio",
18 | "full_name": "Leonardo DiCaprio"
19 | },
20 | {
21 | "id": 4,
22 | "first_name": "Kate",
23 | "last_name": "Winslet",
24 | "full_name": "Kate Winslet"
25 | }
26 | ]
--------------------------------------------------------------------------------
/frontend/api/cinema/movie_sessions:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "show_time": "2023-01-24T11:15:00",
5 | "movie_title": "Ace Ventura",
6 | "movie_image": "media/uploads/movies/ace-ventura.jpg",
7 | "cinema_hall_name": "Red",
8 | "cinema_hall_capacity": 48,
9 | "tickets_available": 48
10 | },
11 | {
12 | "id": 2,
13 | "show_time": "2023-01-26T14:00",
14 | "movie_title": "Ace Ventura",
15 | "movie_image": "media/uploads/movies/ace-ventura.jpg",
16 | "cinema_hall_name": "Red",
17 | "cinema_hall_capacity": 48,
18 | "tickets_available": 46
19 | }
20 | ]
--------------------------------------------------------------------------------
/backend/user/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path
2 | from rest_framework_simplejwt.views import (
3 | TokenObtainPairView,
4 | TokenRefreshView,
5 | TokenVerifyView,
6 | )
7 |
8 | from user.views import CreateUserView, ManageUserView
9 |
10 | app_name = "user"
11 |
12 | urlpatterns = [
13 | path("register/", CreateUserView.as_view(), name="create"),
14 | path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
15 | path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
16 | path("token/verify/", TokenVerifyView.as_view(), name="token_verify"),
17 | path("me/", ManageUserView.as_view(), name="manage"),
18 | ]
19 |
--------------------------------------------------------------------------------
/backend/cinema/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path, include
2 | from rest_framework import routers
3 |
4 | from cinema.views import (
5 | GenreViewSet,
6 | ActorViewSet,
7 | CinemaHallViewSet,
8 | MovieViewSet,
9 | MovieSessionViewSet,
10 | OrderViewSet,
11 | )
12 |
13 | router = routers.DefaultRouter()
14 | router.register("genres", GenreViewSet)
15 | router.register("actors", ActorViewSet)
16 | router.register("cinema_halls", CinemaHallViewSet)
17 | router.register("movies", MovieViewSet)
18 | router.register("movie_sessions", MovieSessionViewSet)
19 | router.register("orders", OrderViewSet)
20 |
21 | urlpatterns = [path("", include(router.urls))]
22 |
23 | app_name = "cinema"
24 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/error.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/api/cinema/movie_sessions-1:
--------------------------------------------------------------------------------
1 | {
2 | "id": 1,
3 | "show_time": "2023-01-26T11:15",
4 | "movie": {
5 | "id": 3,
6 | "title": "Ace Ventura",
7 | "genres": [
8 | "comedy"
9 | ],
10 | "actors": [
11 | "Jim Carrey"
12 | ],
13 | "image": "media/uploads/movies/ace-ventura.jpg"
14 | },
15 | "cinema_hall": {
16 | "id": 1,
17 | "name": "Red",
18 | "rows": 6,
19 | "seats_in_row": 8,
20 | "capacity": 48
21 | },
22 | "taken_places": [
23 | {
24 | "row": 5,
25 | "seat": 4
26 | },
27 | {
28 | "row": 5,
29 | "seat": 5
30 | }
31 | ]
32 | }
--------------------------------------------------------------------------------
/backend/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cinema_service.settings")
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == "__main__":
22 | main()
23 |
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # cinema-shop-vue-ui
2 |
3 | This template should help get you started developing with Vue 3 in Vite.
4 |
5 | ## Recommended IDE Setup
6 |
7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
8 |
9 | ## Customize configuration
10 |
11 | See [Vite Configuration Reference](https://vitejs.dev/config/).
12 |
13 | ## Project Setup
14 |
15 | ```sh
16 | npm install
17 | ```
18 |
19 | ### Compile and Hot-Reload for Development
20 |
21 | ```sh
22 | npm run dev
23 | ```
24 |
25 | ### Compile and Minify for Production
26 |
27 | ```sh
28 | npm run build
29 | ```
30 |
--------------------------------------------------------------------------------
/backend/cinema/management/commands/wait_for_db.py:
--------------------------------------------------------------------------------
1 | import time
2 | from django.db import connections
3 | from django.db.utils import OperationalError
4 | from django.core.management import BaseCommand
5 |
6 |
7 | class Command(BaseCommand):
8 | """Django command to pause execution until db is available"""
9 |
10 | def handle(self, *args, **options):
11 | self.stdout.write("Waiting for database...")
12 | db_conn = None
13 | while not db_conn:
14 | try:
15 | db_conn = connections["default"]
16 | except OperationalError:
17 | self.stdout.write("Database unavailable, waiting 1 second...")
18 | time.sleep(1)
19 |
20 | self.stdout.write(self.style.SUCCESS("Database available!"))
21 |
--------------------------------------------------------------------------------
/frontend/src/comps/AddBtn.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 |
9 |
14 |
15 |
34 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | Cinema Shop
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/backend/user/migrations/0002_alter_user_managers_remove_user_username_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0.4 on 2022-06-14 12:37
2 |
3 | from django.db import migrations, models
4 | import user.models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ("user", "0001_initial"),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterModelManagers(
15 | name="user",
16 | managers=[
17 | ("objects", user.models.UserManager()),
18 | ],
19 | ),
20 | migrations.RemoveField(
21 | model_name="user",
22 | name="username",
23 | ),
24 | migrations.AlterField(
25 | model_name="user",
26 | name="email",
27 | field=models.EmailField(
28 | max_length=254, unique=True, verbose_name="email address"
29 | ),
30 | ),
31 | ]
32 |
--------------------------------------------------------------------------------
/backend/user/serializers.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth import get_user_model
2 | from rest_framework import serializers
3 |
4 |
5 | class UserSerializer(serializers.ModelSerializer):
6 | class Meta:
7 | model = get_user_model()
8 | fields = ("id", "email", "password", "is_staff")
9 | read_only_fields = ("is_staff",)
10 | extra_kwargs = {"password": {"write_only": True, "min_length": 5}}
11 |
12 | def create(self, validated_data):
13 | """Create a new user with encrypted password and return it"""
14 | return get_user_model().objects.create_user(**validated_data)
15 |
16 | def update(self, instance, validated_data):
17 | """Update a user, set the password correctly and return it"""
18 | password = validated_data.pop("password", None)
19 | user = super().update(instance, validated_data)
20 | if password:
21 | user.set_password(password)
22 | user.save()
23 |
24 | return user
25 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cinema-shop-vue-ui",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "dev": "vite",
6 | "build": "vite build",
7 | "preview": "vite preview --port 4173"
8 | },
9 | "dependencies": {
10 | "axios": "^1.1.3",
11 | "jwt-decode": "^3.1.2",
12 | "lodash.debounce": "^4.0.8",
13 | "lodash.range": "^3.2.0",
14 | "moment": "^2.29.4",
15 | "v-calendar": "^2.4.1",
16 | "vue": "^2.7.7",
17 | "vue-axios": "^3.5.2"
18 | },
19 | "devDependencies": {
20 | "@vitejs/plugin-legacy": "^2.0.0",
21 | "@vitejs/plugin-vue2": "^1.1.2",
22 | "eslint": "^8.27.0",
23 | "eslint-config-semistandard": "^16.0.0",
24 | "eslint-config-standard": "^17.0.0",
25 | "eslint-plugin-import": "^2.26.0",
26 | "eslint-plugin-n": "^15.5.0",
27 | "eslint-plugin-promise": "^6.1.1",
28 | "eslint-plugin-vue": "^9.7.0",
29 | "semistandard": "^16.0.1",
30 | "terser": "^5.14.2",
31 | "vite": "^3.0.2"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/backend/cinema_service/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.conf.urls.static import static
3 | from django.contrib import admin
4 | from django.urls import path, include
5 | from drf_spectacular.views import (
6 | SpectacularAPIView,
7 | SpectacularSwaggerView,
8 | SpectacularRedocView,
9 | )
10 |
11 | urlpatterns = [
12 | path("admin/", admin.site.urls),
13 | path("api/cinema/", include("cinema.urls", namespace="cinema")),
14 | path("api/user/", include("user.urls", namespace="user")),
15 | path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
16 | path(
17 | "api/doc/swagger/",
18 | SpectacularSwaggerView.as_view(url_name="schema"),
19 | name="swagger-ui",
20 | ),
21 | path(
22 | "api/doc/redoc/",
23 | SpectacularRedocView.as_view(url_name="schema"),
24 | name="redoc",
25 | ),
26 | path("__debug__/", include("debug_toolbar.urls")),
27 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
28 |
--------------------------------------------------------------------------------
/frontend/src/comps/CheckboxItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
9 |
10 |
11 |
12 |
27 |
28 |
48 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/clock.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/api/cinema/movies:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "title": "Ace Ventura",
5 | "genres": [
6 | "comedy"
7 | ],
8 | "actors": [
9 | "Jim Carrey"
10 | ],
11 | "image": "media/uploads/movies/ace-ventura.jpg"
12 | },
13 | {
14 | "id": 2,
15 | "title": "Liar Liar",
16 | "genres": [
17 | "comedy"
18 | ],
19 | "actors": [
20 | "Jim Carrey"
21 | ],
22 | "image": "media/uploads/movies/liar-liar.jpeg"
23 | },
24 | {
25 | "id": 3,
26 | "title": "Spider Man: No Way Home",
27 | "genres": [
28 | "fantasy"
29 | ],
30 | "actors": [
31 | "Tom Holland"
32 | ],
33 | "image": "media/uploads/movies/spider-man-no-way-home.jpeg"
34 | },
35 | {
36 | "id": 4,
37 | "title": "Titanic",
38 | "genres": [
39 | "drama",
40 | "romance"
41 | ],
42 | "actors": [
43 | "Leonardo DiCaprio",
44 | "Kate Winslet"
45 | ],
46 | "image": "media/uploads/movies/titanic.jpeg"
47 | }
48 | ]
--------------------------------------------------------------------------------
/backend/user/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
3 | from django.utils.translation import gettext as _
4 |
5 | from .models import User
6 |
7 |
8 | @admin.register(User)
9 | class UserAdmin(DjangoUserAdmin):
10 | """Define admin model for custom User model with no email field."""
11 |
12 | fieldsets = (
13 | (None, {"fields": ("email", "password")}),
14 | (_("Personal info"), {"fields": ("first_name", "last_name")}),
15 | (
16 | _("Permissions"),
17 | {
18 | "fields": (
19 | "is_active",
20 | "is_staff",
21 | "is_superuser",
22 | "groups",
23 | "user_permissions",
24 | )
25 | },
26 | ),
27 | (_("Important dates"), {"fields": ("last_login", "date_joined")}),
28 | )
29 | add_fieldsets = (
30 | (
31 | None,
32 | {
33 | "classes": ("wide",),
34 | "fields": ("email", "password1", "password2"),
35 | },
36 | ),
37 | )
38 | list_display = ("email", "first_name", "last_name", "is_staff")
39 | search_fields = ("email", "first_name", "last_name")
40 | ordering = ("email",)
41 |
--------------------------------------------------------------------------------
/frontend/src/comps/ImageUploader.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
17 |
18 |
61 |
--------------------------------------------------------------------------------
/frontend/api/cinema/orders:
--------------------------------------------------------------------------------
1 | {
2 | "count": 1,
3 | "next": null,
4 | "previous": null,
5 | "results": [
6 | {
7 | "id": 1,
8 | "tickets": [
9 | {
10 | "id": 1,
11 | "row": 5,
12 | "seat": 4,
13 | "movie_session": {
14 | "id": 1,
15 | "show_time": "2023-01-26T11:15",
16 | "movie_title": "Ace Ventura",
17 | "movie_image": "media/uploads/movies/ace-ventura.jpg",
18 | "cinema_hall_name": "Red",
19 | "cinema_hall_capacity": 48
20 | }
21 | },
22 | {
23 | "id": 2,
24 | "row": 5,
25 | "seat": 5,
26 | "movie_session": {
27 | "id": 1,
28 | "show_time": "2023-01-26T11:15",
29 | "movie_title": "Ace Ventura",
30 | "movie_image": "media/uploads/movies/ace-ventura.jpg",
31 | "cinema_hall_name": "Red",
32 | "cinema_hall_capacity": 48
33 | }
34 | }
35 | ],
36 | "created_at": "2023-01-26T12:38:52.312498"
37 | }
38 | ]
39 | }
--------------------------------------------------------------------------------
/frontend/src/comps/ActionButton.vue:
--------------------------------------------------------------------------------
1 |
2 | {{label}}
11 |
12 |
13 |
44 |
45 |
81 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/eye_crossed.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/comps/TimePicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Select time range
4 |
5 |
6 |
11 |
12 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
38 |
39 |
81 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/eye.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/user/models.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import (
2 | AbstractUser,
3 | BaseUserManager,
4 | )
5 | from django.db import models
6 | from django.utils.translation import gettext as _
7 |
8 |
9 | class UserManager(BaseUserManager):
10 | """Define a model manager for User model with no username field."""
11 |
12 | use_in_migrations = True
13 |
14 | def _create_user(self, email, password, **extra_fields):
15 | """Create and save a User with the given email and password."""
16 | if not email:
17 | raise ValueError("The given email must be set")
18 | email = self.normalize_email(email)
19 | user = self.model(email=email, **extra_fields)
20 | user.set_password(password)
21 | user.save(using=self._db)
22 | return user
23 |
24 | def create_user(self, email, password=None, **extra_fields):
25 | """Create and save a regular User with the given email and password."""
26 | extra_fields.setdefault("is_staff", False)
27 | extra_fields.setdefault("is_superuser", False)
28 | return self._create_user(email, password, **extra_fields)
29 |
30 | def create_superuser(self, email, password, **extra_fields):
31 | """Create and save a SuperUser with the given email and password."""
32 | extra_fields.setdefault("is_staff", True)
33 | extra_fields.setdefault("is_superuser", True)
34 |
35 | if extra_fields.get("is_staff") is not True:
36 | raise ValueError("Superuser must have is_staff=True.")
37 | if extra_fields.get("is_superuser") is not True:
38 | raise ValueError("Superuser must have is_superuser=True.")
39 |
40 | return self._create_user(email, password, **extra_fields)
41 |
42 |
43 | class User(AbstractUser):
44 | username = None
45 | email = models.EmailField(_("email address"), unique=True)
46 |
47 | USERNAME_FIELD = "email"
48 | REQUIRED_FIELDS = []
49 |
50 | objects = UserManager()
51 |
--------------------------------------------------------------------------------
/frontend/src/assets/main.css:
--------------------------------------------------------------------------------
1 | *,
2 | *::before,
3 | *::after {
4 | box-sizing: border-box;
5 | margin: 0;
6 | position: relative;
7 | font-weight: normal;
8 | }
9 |
10 | body {
11 | --main-bg: #111;
12 | --secondary-bg: #222;
13 | --main-font: #fff;
14 | --red: #FF2B2B;
15 |
16 | --border: #666;
17 | --input-placeholder: #D2D2D2;
18 |
19 | min-height: 100vh;
20 | color: var(--main-font);
21 | background: var(--main-bg);
22 | transition: color 0.5s, background-color 0.5s;
23 | line-height: 1.6;
24 | font-family: 'Montserrat', sans-serif;
25 | font-size: 15px;
26 | text-rendering: optimizeLegibility;
27 | -webkit-font-smoothing: antialiased;
28 | -moz-osx-font-smoothing: grayscale;
29 |
30 | }
31 |
32 | input, textarea {
33 | font-family: 'Montserrat', sans-serif;
34 | }
35 |
36 | #app {
37 | display: grid;
38 | grid-template-rows: 80px 1fr 60px;
39 | margin: 0 auto;
40 | height: 100vh;
41 | }
42 |
43 | .vc-container.vc-is-dark {
44 | background-color: var(--secondary-bg);
45 | border-color: var(--border);
46 | color: var(--main-font);
47 | font-family: 'Montserrat', sans-serif;
48 | }
49 |
50 | .vc-is-dark .vc-weekday {
51 | color: var(--red) !important;
52 | font-weight: 400;
53 | }
54 |
55 | .vc-highlight {
56 | background-color: var(--red) !important;
57 | }
58 |
59 | .vc-is-dark .vc-nav-title {
60 | color: var(--main-font);
61 | pointer-events: none;
62 | }
63 |
64 | .vc-is-dark .vc-nav-popover-container {
65 | color: var(--main-font);
66 | background-color: var(--secondary-bg);
67 | border-color: var(--border);
68 | }
69 |
70 | .vc-is-dark .vc-nav-item.is-active {
71 | background-color: var(--red);
72 | }
73 |
74 | .vc-is-dark .vc-nav-item:focus {
75 | border-color: transparent;
76 | }
77 |
78 | .vc-is-dark .vc-day-content.is-disabled {
79 | color: var(--border) !important;
80 | }
81 |
82 | .vc-date {
83 | display: none !important;
84 | }
85 |
86 | .vc-is-dark select {
87 | background-color: var(--secondary-bg) !important;
88 | border-color: transparent !important;
89 | }
--------------------------------------------------------------------------------
/frontend/src/comps/DatePicker.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Select date
4 |
5 |
6 |
11 |
12 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
41 |
42 |
84 |
--------------------------------------------------------------------------------
/frontend/src/views/MovieDetailsScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
70 |
71 |
84 |
--------------------------------------------------------------------------------
/frontend/src/comps/InputItem.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{label}}
4 |
5 |
12 |
13 |
14 |
15 |
16 |
61 |
62 |
117 |
--------------------------------------------------------------------------------
/frontend/src/views/SignIn.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Sign in to Cinema Shop
4 |
Please enter your sign in details.
5 | Sign up
6 | here if you are not registered yet.
7 |
12 |
13 |
14 |
15 |
16 |
17 |
65 |
66 |
101 |
--------------------------------------------------------------------------------
/frontend/src/comps/MovieModal.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
8 |
9 |
10 |
![]()
11 |
12 |
{{movie.title}}
13 |
14 | Actors:
15 | {{actor.first_name}} {{actor.last_name}}
16 |
17 |
18 | Genres:
19 | {{genre.name}}
20 |
21 |
{{movie.description}}
22 |
23 |
24 |
25 |
26 |
27 |
37 |
38 |
115 |
--------------------------------------------------------------------------------
/frontend/src/comps/PasswordInput.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Password
4 |
5 |
12 |
20 |
28 |
29 |
30 |
31 |
32 |
57 |
58 |
113 |
--------------------------------------------------------------------------------
/frontend/src/views/SignUp.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Sign up to Cinema Shop
4 |
Please enter your credentials to sign up.
5 | Sign in
6 | here if you are registered yet.
7 |
12 |
13 |
14 |
15 |
16 |
17 |
70 |
71 |
106 |
--------------------------------------------------------------------------------
/frontend/src/views/CinemaHallListScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
Cinema Halls
4 |
5 |
6 |
{{hall.name}}
7 |
Size: {{hall.rows}} x {{hall.seats_in_row}}
8 |
Capacity: {{hall.capacity}}
9 |
10 |
11 |
12 |
13 |
14 |
15 |
74 |
75 |
119 |
--------------------------------------------------------------------------------
/frontend/src/views/CinemaHallAddScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Please fill in the fields in details
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
84 |
85 |
121 |
--------------------------------------------------------------------------------
/frontend/src/comps/MovieCard.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
{{title}}
5 |
6 |
9 |
10 |
11 |
12 |
13 | Actors:
14 | {{actor}}
15 |
16 |
17 | Genres:
18 | {{genre}}
19 |
20 |
21 | Time:
22 | {{time}}
23 |
24 |
25 |
26 |
27 |
28 |
65 |
66 |
129 |
--------------------------------------------------------------------------------
/frontend/public/assets/icons/cart.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/views/AppHeader.vue:
--------------------------------------------------------------------------------
1 |
2 |
28 |
29 |
30 |
78 |
79 |
142 |
--------------------------------------------------------------------------------
/frontend/src/views/ProfileScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
27 |
28 |
29 |
109 |
110 |
136 |
--------------------------------------------------------------------------------
/frontend/src/comps/CustomSelect.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
{{label}}
7 |
8 |
9 | {{selectedOption.name}}
10 |
11 |
12 |
15 |
16 |
17 |
18 |
19 | {{option.name}}
20 |
25 |
26 |
27 |
28 |
29 |
30 |
68 |
69 |
154 |
--------------------------------------------------------------------------------
/frontend/src/comps/CustomMultiselect.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
{{label}}
7 |
8 |
9 | {{option.name}}
10 |
11 |
12 |
15 |
16 |
17 |
18 |
19 | {{option.name}}
20 |
24 |
25 |
26 |
27 |
28 |
29 |
66 |
67 |
152 |
--------------------------------------------------------------------------------
/frontend/src/views/GenreListScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Please fill in the fields in details
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | {{genre.name}}
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
109 |
110 |
157 |
--------------------------------------------------------------------------------
/frontend/src/comps/CinemaHallSchema.vue:
--------------------------------------------------------------------------------
1 |
2 |
27 |
28 |
29 |
79 |
80 |
154 |
--------------------------------------------------------------------------------
/frontend/src/views/MovieSessionListScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
13 |
14 |
No movie sessions for selected date.
15 |
18 |
19 |
20 |
21 |
22 |
123 |
124 |
151 |
--------------------------------------------------------------------------------
/frontend/src/views/ActorListScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Please fill in the fields in details
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | {{actor.first_name}} {{actor.last_name}}
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
116 |
117 |
169 |
--------------------------------------------------------------------------------
/frontend/src/views/MovieSessionDetailsScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{movieSession.movie.title}}
7 | Date: {{formattedDate}}
8 | Time: {{formattedTime}}
9 | Cinema Hall: {{movieSession.cinema_hall.name}}
10 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
118 |
119 |
156 |
--------------------------------------------------------------------------------
/backend/cinema/models.py:
--------------------------------------------------------------------------------
1 | import os
2 | import uuid
3 |
4 | from django.core.exceptions import ValidationError
5 | from django.db import models
6 | from django.conf import settings
7 | from django.utils.text import slugify
8 |
9 |
10 | class CinemaHall(models.Model):
11 | name = models.CharField(max_length=255)
12 | rows = models.IntegerField()
13 | seats_in_row = models.IntegerField()
14 |
15 | @property
16 | def capacity(self) -> int:
17 | return self.rows * self.seats_in_row
18 |
19 | def __str__(self):
20 | return self.name
21 |
22 |
23 | class Genre(models.Model):
24 | name = models.CharField(max_length=255, unique=True)
25 |
26 | def __str__(self):
27 | return self.name
28 |
29 |
30 | class Actor(models.Model):
31 | first_name = models.CharField(max_length=255)
32 | last_name = models.CharField(max_length=255)
33 |
34 | def __str__(self):
35 | return self.first_name + " " + self.last_name
36 |
37 | @property
38 | def full_name(self):
39 | return f"{self.first_name} {self.last_name}"
40 |
41 |
42 | def movie_image_file_path(instance, filename):
43 | _, extension = os.path.splitext(filename)
44 | filename = f"{slugify(instance.title)}-{uuid.uuid4()}{extension}"
45 |
46 | return os.path.join("uploads/movies/", filename)
47 |
48 |
49 | class Movie(models.Model):
50 | title = models.CharField(max_length=255)
51 | description = models.TextField()
52 | duration = models.IntegerField()
53 | genres = models.ManyToManyField(Genre, blank=True)
54 | actors = models.ManyToManyField(Actor, blank=True)
55 | image = models.ImageField(null=True, upload_to=movie_image_file_path)
56 |
57 | class Meta:
58 | ordering = ["title"]
59 |
60 | def __str__(self):
61 | return self.title
62 |
63 |
64 | class MovieSession(models.Model):
65 | show_time = models.DateTimeField()
66 | movie = models.ForeignKey(Movie, on_delete=models.CASCADE)
67 | cinema_hall = models.ForeignKey(CinemaHall, on_delete=models.CASCADE)
68 |
69 | class Meta:
70 | ordering = ["-show_time"]
71 |
72 | def __str__(self):
73 | return self.movie.title + " " + str(self.show_time)
74 |
75 |
76 | class Order(models.Model):
77 | created_at = models.DateTimeField(auto_now_add=True)
78 | user = models.ForeignKey(
79 | settings.AUTH_USER_MODEL, on_delete=models.CASCADE
80 | )
81 |
82 | def __str__(self):
83 | return str(self.created_at)
84 |
85 | class Meta:
86 | ordering = ["-created_at"]
87 |
88 |
89 | class Ticket(models.Model):
90 | movie_session = models.ForeignKey(
91 | MovieSession, on_delete=models.CASCADE, related_name="tickets"
92 | )
93 | order = models.ForeignKey(
94 | Order, on_delete=models.CASCADE, related_name="tickets"
95 | )
96 | row = models.IntegerField()
97 | seat = models.IntegerField()
98 |
99 | @staticmethod
100 | def validate_ticket(row, seat, cinema_hall, error_to_raise):
101 | for ticket_attr_value, ticket_attr_name, cinema_hall_attr_name in [
102 | (row, "row", "rows"),
103 | (seat, "seat", "seats_in_row"),
104 | ]:
105 | count_attrs = getattr(cinema_hall, cinema_hall_attr_name)
106 | if not (1 <= ticket_attr_value <= count_attrs):
107 | raise error_to_raise(
108 | {
109 | ticket_attr_name: f"{ticket_attr_name} "
110 | f"number must be in available range: "
111 | f"(1, {cinema_hall_attr_name}): "
112 | f"(1, {count_attrs})"
113 | }
114 | )
115 |
116 | def clean(self):
117 | Ticket.validate_ticket(
118 | self.row,
119 | self.seat,
120 | self.movie_session.cinema_hall,
121 | ValidationError,
122 | )
123 |
124 | def save(
125 | self,
126 | force_insert=False,
127 | force_update=False,
128 | using=None,
129 | update_fields=None,
130 | ):
131 | self.full_clean()
132 | return super(Ticket, self).save(
133 | force_insert, force_update, using, update_fields
134 | )
135 |
136 | def __str__(self):
137 | return (
138 | f"{str(self.movie_session)} (row: {self.row}, seat: {self.seat})"
139 | )
140 |
141 | class Meta:
142 | unique_together = ("movie_session", "row", "seat")
143 | ordering = ["row", "seat"]
144 |
--------------------------------------------------------------------------------
/frontend/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
132 |
133 |
146 |
--------------------------------------------------------------------------------
/frontend/src/views/MovieSessionAddScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Please fill in the fields in details
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
145 |
146 |
186 |
--------------------------------------------------------------------------------
/backend/cinema/serializers.py:
--------------------------------------------------------------------------------
1 | from django.db import transaction
2 | from rest_framework import serializers
3 | from rest_framework.exceptions import ValidationError
4 |
5 | from cinema.models import (
6 | Genre,
7 | Actor,
8 | CinemaHall,
9 | Movie,
10 | MovieSession,
11 | Ticket,
12 | Order,
13 | )
14 |
15 |
16 | class GenreSerializer(serializers.ModelSerializer):
17 | class Meta:
18 | model = Genre
19 | fields = ("id", "name")
20 |
21 |
22 | class ActorSerializer(serializers.ModelSerializer):
23 | class Meta:
24 | model = Actor
25 | fields = ("id", "first_name", "last_name", "full_name")
26 |
27 |
28 | class CinemaHallSerializer(serializers.ModelSerializer):
29 | class Meta:
30 | model = CinemaHall
31 | fields = ("id", "name", "rows", "seats_in_row", "capacity")
32 |
33 |
34 | class MovieSerializer(serializers.ModelSerializer):
35 | class Meta:
36 | model = Movie
37 | fields = (
38 | "id",
39 | "title",
40 | "description",
41 | "duration",
42 | "genres",
43 | "actors",
44 | )
45 |
46 |
47 | class MovieListSerializer(MovieSerializer):
48 | genres = serializers.SlugRelatedField(
49 | many=True, read_only=True, slug_field="name"
50 | )
51 | actors = serializers.SlugRelatedField(
52 | many=True, read_only=True, slug_field="full_name"
53 | )
54 |
55 | class Meta:
56 | model = Movie
57 | fields = ("id", "title", "genres", "actors", "image")
58 |
59 |
60 | class MovieDetailSerializer(MovieSerializer):
61 | genres = GenreSerializer(many=True, read_only=True)
62 | actors = ActorSerializer(many=True, read_only=True)
63 |
64 | class Meta:
65 | model = Movie
66 | fields = (
67 | "id",
68 | "title",
69 | "duration",
70 | "description",
71 | "genres",
72 | "actors",
73 | "image",
74 | )
75 |
76 |
77 | class MovieImageSerializer(serializers.ModelSerializer):
78 | class Meta:
79 | model = Movie
80 | fields = ("id", "image")
81 |
82 |
83 | class MovieSessionSerializer(serializers.ModelSerializer):
84 | class Meta:
85 | model = MovieSession
86 | fields = ("id", "show_time", "movie", "cinema_hall")
87 |
88 |
89 | class MovieSessionListSerializer(MovieSessionSerializer):
90 | movie_title = serializers.CharField(source="movie.title", read_only=True)
91 | movie_image = serializers.ImageField(source="movie.image", read_only=True)
92 | cinema_hall_name = serializers.CharField(
93 | source="cinema_hall.name", read_only=True
94 | )
95 | cinema_hall_capacity = serializers.IntegerField(
96 | source="cinema_hall.capacity", read_only=True
97 | )
98 | tickets_available = serializers.IntegerField(read_only=True)
99 |
100 | class Meta:
101 | model = MovieSession
102 | fields = (
103 | "id",
104 | "show_time",
105 | "movie_title",
106 | "movie_image",
107 | "cinema_hall_name",
108 | "cinema_hall_capacity",
109 | "tickets_available",
110 | )
111 |
112 |
113 | class TicketSerializer(serializers.ModelSerializer):
114 | def validate(self, attrs):
115 | data = super(TicketSerializer, self).validate(attrs=attrs)
116 | Ticket.validate_ticket(
117 | attrs["row"], attrs["seat"], data["movie_session"].cinema_hall, ValidationError
118 | )
119 | return data
120 |
121 | class Meta:
122 | model = Ticket
123 | fields = ("id", "row", "seat", "movie_session")
124 |
125 |
126 | class TicketListSerializer(TicketSerializer):
127 | movie_session = MovieSessionListSerializer(many=False, read_only=True)
128 |
129 |
130 | class TicketSeatsSerializer(TicketSerializer):
131 | class Meta:
132 | model = Ticket
133 | fields = ("row", "seat")
134 |
135 |
136 | class MovieSessionDetailSerializer(MovieSessionSerializer):
137 | movie = MovieListSerializer(many=False, read_only=True)
138 | cinema_hall = CinemaHallSerializer(many=False, read_only=True)
139 | taken_places = TicketSeatsSerializer(
140 | source="tickets", many=True, read_only=True
141 | )
142 |
143 | class Meta:
144 | model = MovieSession
145 | fields = ("id", "show_time", "movie", "cinema_hall", "taken_places")
146 |
147 |
148 | class OrderSerializer(serializers.ModelSerializer):
149 | tickets = TicketSerializer(many=True, read_only=False, allow_empty=False)
150 |
151 | class Meta:
152 | model = Order
153 | fields = ("id", "tickets", "created_at")
154 |
155 | def create(self, validated_data):
156 | with transaction.atomic():
157 | tickets_data = validated_data.pop("tickets")
158 | order = Order.objects.create(**validated_data)
159 | for ticket_data in tickets_data:
160 | Ticket.objects.create(order=order, **ticket_data)
161 | return order
162 |
163 |
164 | class OrderListSerializer(OrderSerializer):
165 | tickets = TicketListSerializer(many=True, read_only=True)
166 |
--------------------------------------------------------------------------------
/frontend/src/views/MovieListScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
10 |
16 |
17 |
18 |
24 |
25 |
28 |
29 |
30 |
31 |
32 |
167 |
168 |
182 |
--------------------------------------------------------------------------------
/backend/cinema_service/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for cinema_service project.
3 |
4 | Generated by 'django-admin startproject' using Django 4.0.4.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/4.0/topics/settings/
8 |
9 | For the full list of settings and their values, see
10 | https://docs.djangoproject.com/en/4.0/ref/settings/
11 | """
12 | import os
13 | from datetime import timedelta
14 | from pathlib import Path
15 |
16 | # Build paths inside the project like this: BASE_DIR / 'subdir'.
17 | BASE_DIR = Path(__file__).resolve().parent.parent
18 |
19 |
20 | # Quick-start development settings - unsuitable for production
21 | # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
22 |
23 | # SECURITY WARNING: keep the secret key used in production secret!
24 | SECRET_KEY = (
25 | "django-insecure-6vubhk2$++agnctay_4pxy_8cq)mosmn(*-#2b^v4cgsh-^!i3"
26 | )
27 |
28 | # SECURITY WARNING: don't run with debug turned on in production!
29 | DEBUG = True
30 |
31 | ALLOWED_HOSTS = []
32 |
33 | INTERNAL_IPS = [
34 | "127.0.0.1",
35 | ]
36 |
37 | # Application definition
38 |
39 | INSTALLED_APPS = [
40 | "django.contrib.admin",
41 | "django.contrib.auth",
42 | "django.contrib.contenttypes",
43 | "django.contrib.sessions",
44 | "django.contrib.messages",
45 | "django.contrib.staticfiles",
46 | "rest_framework",
47 | "drf_spectacular",
48 | "debug_toolbar",
49 | "cinema",
50 | "user",
51 | ]
52 |
53 | MIDDLEWARE = [
54 | "django.middleware.security.SecurityMiddleware",
55 | "debug_toolbar.middleware.DebugToolbarMiddleware",
56 | "django.contrib.sessions.middleware.SessionMiddleware",
57 | "django.middleware.common.CommonMiddleware",
58 | "django.middleware.csrf.CsrfViewMiddleware",
59 | "django.contrib.auth.middleware.AuthenticationMiddleware",
60 | "django.contrib.messages.middleware.MessageMiddleware",
61 | "django.middleware.clickjacking.XFrameOptionsMiddleware",
62 | ]
63 |
64 | ROOT_URLCONF = "cinema_service.urls"
65 |
66 | TEMPLATES = [
67 | {
68 | "BACKEND": "django.template.backends.django.DjangoTemplates",
69 | "DIRS": [],
70 | "APP_DIRS": True,
71 | "OPTIONS": {
72 | "context_processors": [
73 | "django.template.context_processors.debug",
74 | "django.template.context_processors.request",
75 | "django.contrib.auth.context_processors.auth",
76 | "django.contrib.messages.context_processors.messages",
77 | ],
78 | },
79 | },
80 | ]
81 |
82 | WSGI_APPLICATION = "cinema_service.wsgi.application"
83 |
84 |
85 | # Database
86 | # https://docs.djangoproject.com/en/4.0/ref/settings/#databases
87 |
88 | DATABASES = {
89 | "default": {
90 | "ENGINE": "django.db.backends.postgresql",
91 | "HOST": os.environ["POSTGRES_HOST"],
92 | "NAME": os.environ["POSTGRES_DB"],
93 | "USER": os.environ["POSTGRES_USER"],
94 | "PASSWORD": os.environ["POSTGRES_PASSWORD"],
95 | }
96 | }
97 |
98 |
99 | # Password validation
100 | # https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
101 |
102 | AUTH_PASSWORD_VALIDATORS = [
103 | {
104 | "NAME": "django.contrib.auth.password_validation."
105 | "UserAttributeSimilarityValidator",
106 | },
107 | {
108 | "NAME": "django.contrib.auth.password_validation."
109 | "MinimumLengthValidator",
110 | },
111 | {
112 | "NAME": "django.contrib.auth.password_validation."
113 | "CommonPasswordValidator",
114 | },
115 | {
116 | "NAME": "django.contrib.auth.password_validation."
117 | "NumericPasswordValidator",
118 | },
119 | ]
120 |
121 | AUTH_USER_MODEL = "user.User"
122 |
123 | # Internationalization
124 | # https://docs.djangoproject.com/en/4.0/topics/i18n/
125 |
126 | LANGUAGE_CODE = "en-us"
127 |
128 | TIME_ZONE = "UTC"
129 |
130 | USE_I18N = True
131 |
132 | USE_TZ = False
133 |
134 |
135 | # Static files (CSS, JavaScript, Images)
136 | # https://docs.djangoproject.com/en/4.0/howto/static-files/
137 |
138 | STATIC_URL = "static/"
139 |
140 | MEDIA_URL = "/media/"
141 | MEDIA_ROOT = "/vol/web/media"
142 |
143 | # Default primary key field type
144 | # https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
145 |
146 | DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
147 |
148 | REST_FRAMEWORK = {
149 | "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
150 | "DEFAULT_THROTTLE_CLASSES": [
151 | "rest_framework.throttling.AnonRateThrottle",
152 | "rest_framework.throttling.UserRateThrottle",
153 | ],
154 | "DEFAULT_THROTTLE_RATES": {"anon": "10000/day", "user": "10000/day"},
155 | "DEFAULT_AUTHENTICATION_CLASSES": (
156 | "rest_framework_simplejwt.authentication.JWTAuthentication",
157 | ),
158 | }
159 |
160 | SPECTACULAR_SETTINGS = {
161 | "TITLE": "Cinema Service API",
162 | "DESCRIPTION": "Order cinema tickets",
163 | "VERSION": "1.0.0",
164 | "SERVE_INCLUDE_SCHEMA": False,
165 | "SWAGGER_UI_SETTINGS": {
166 | "deepLinking": True,
167 | "defaultModelRendering": "model",
168 | "defaultModelsExpandDepth": 2,
169 | "defaultModelExpandDepth": 2,
170 | },
171 | }
172 |
173 | SIMPLE_JWT = {
174 | "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60 * 60),
175 | "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
176 | "ROTATE_REFRESH_TOKENS": False,
177 | }
178 |
--------------------------------------------------------------------------------
/backend/user/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0.4 on 2022-05-10 11:54
2 |
3 | import django.contrib.auth.models
4 | import django.contrib.auth.validators
5 | from django.db import migrations, models
6 | import django.utils.timezone
7 |
8 |
9 | class Migration(migrations.Migration):
10 |
11 | initial = True
12 |
13 | dependencies = [
14 | ("auth", "0012_alter_user_first_name_max_length"),
15 | ]
16 |
17 | operations = [
18 | migrations.CreateModel(
19 | name="User",
20 | fields=[
21 | (
22 | "id",
23 | models.BigAutoField(
24 | auto_created=True,
25 | primary_key=True,
26 | serialize=False,
27 | verbose_name="ID",
28 | ),
29 | ),
30 | (
31 | "password",
32 | models.CharField(max_length=128, verbose_name="password"),
33 | ),
34 | (
35 | "last_login",
36 | models.DateTimeField(
37 | blank=True, null=True, verbose_name="last login"
38 | ),
39 | ),
40 | (
41 | "is_superuser",
42 | models.BooleanField(
43 | default=False,
44 | help_text="Designates that this user has all permissions without explicitly assigning them.",
45 | verbose_name="superuser status",
46 | ),
47 | ),
48 | (
49 | "username",
50 | models.CharField(
51 | error_messages={
52 | "unique": "A user with that username already exists."
53 | },
54 | help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
55 | max_length=150,
56 | unique=True,
57 | validators=[
58 | django.contrib.auth.validators.UnicodeUsernameValidator()
59 | ],
60 | verbose_name="username",
61 | ),
62 | ),
63 | (
64 | "first_name",
65 | models.CharField(
66 | blank=True, max_length=150, verbose_name="first name"
67 | ),
68 | ),
69 | (
70 | "last_name",
71 | models.CharField(
72 | blank=True, max_length=150, verbose_name="last name"
73 | ),
74 | ),
75 | (
76 | "email",
77 | models.EmailField(
78 | blank=True,
79 | max_length=254,
80 | verbose_name="email address",
81 | ),
82 | ),
83 | (
84 | "is_staff",
85 | models.BooleanField(
86 | default=False,
87 | help_text="Designates whether the user can log into this admin site.",
88 | verbose_name="staff status",
89 | ),
90 | ),
91 | (
92 | "is_active",
93 | models.BooleanField(
94 | default=True,
95 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
96 | verbose_name="active",
97 | ),
98 | ),
99 | (
100 | "date_joined",
101 | models.DateTimeField(
102 | default=django.utils.timezone.now,
103 | verbose_name="date joined",
104 | ),
105 | ),
106 | (
107 | "groups",
108 | models.ManyToManyField(
109 | blank=True,
110 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
111 | related_name="user_set",
112 | related_query_name="user",
113 | to="auth.group",
114 | verbose_name="groups",
115 | ),
116 | ),
117 | (
118 | "user_permissions",
119 | models.ManyToManyField(
120 | blank=True,
121 | help_text="Specific permissions for this user.",
122 | related_name="user_set",
123 | related_query_name="user",
124 | to="auth.permission",
125 | verbose_name="user permissions",
126 | ),
127 | ),
128 | ],
129 | options={
130 | "verbose_name": "user",
131 | "verbose_name_plural": "users",
132 | "abstract": False,
133 | },
134 | managers=[
135 | ("objects", django.contrib.auth.models.UserManager()),
136 | ],
137 | ),
138 | ]
139 |
--------------------------------------------------------------------------------
/frontend/src/views/OrderListScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
Id: {{order.id}}.
8 |
Created at {{createdAt(order.created_at)}}
9 |
10 |
11 |
12 |
13 |
Movie: {{ticket.movie_session.movie_title}}
14 |
Show time: {{showTime(ticket.movie_session.show_time)}}
15 |
Row: {{ticket.row}}
16 |
Seat: {{ticket.seat}}
17 |
18 |
19 |
20 |
21 |
22 |
25 |
28 |
29 |
32 |
35 |
36 |
37 |
38 |
39 |
40 |
115 |
116 |
196 |
--------------------------------------------------------------------------------
/backend/cinema/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0.4 on 2022-06-14 12:26
2 |
3 | import cinema.models
4 | from django.conf import settings
5 | from django.db import migrations, models
6 | import django.db.models.deletion
7 |
8 |
9 | class Migration(migrations.Migration):
10 |
11 | initial = True
12 |
13 | dependencies = [
14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15 | ]
16 |
17 | operations = [
18 | migrations.CreateModel(
19 | name="Actor",
20 | fields=[
21 | (
22 | "id",
23 | models.BigAutoField(
24 | auto_created=True,
25 | primary_key=True,
26 | serialize=False,
27 | verbose_name="ID",
28 | ),
29 | ),
30 | ("first_name", models.CharField(max_length=255)),
31 | ("last_name", models.CharField(max_length=255)),
32 | ],
33 | ),
34 | migrations.CreateModel(
35 | name="CinemaHall",
36 | fields=[
37 | (
38 | "id",
39 | models.BigAutoField(
40 | auto_created=True,
41 | primary_key=True,
42 | serialize=False,
43 | verbose_name="ID",
44 | ),
45 | ),
46 | ("name", models.CharField(max_length=255)),
47 | ("rows", models.IntegerField()),
48 | ("seats_in_row", models.IntegerField()),
49 | ],
50 | ),
51 | migrations.CreateModel(
52 | name="Genre",
53 | fields=[
54 | (
55 | "id",
56 | models.BigAutoField(
57 | auto_created=True,
58 | primary_key=True,
59 | serialize=False,
60 | verbose_name="ID",
61 | ),
62 | ),
63 | ("name", models.CharField(max_length=255, unique=True)),
64 | ],
65 | ),
66 | migrations.CreateModel(
67 | name="Movie",
68 | fields=[
69 | (
70 | "id",
71 | models.BigAutoField(
72 | auto_created=True,
73 | primary_key=True,
74 | serialize=False,
75 | verbose_name="ID",
76 | ),
77 | ),
78 | ("title", models.CharField(max_length=255)),
79 | ("description", models.TextField()),
80 | ("duration", models.IntegerField()),
81 | (
82 | "image",
83 | models.ImageField(
84 | null=True,
85 | upload_to=cinema.models.movie_image_file_path,
86 | ),
87 | ),
88 | (
89 | "actors",
90 | models.ManyToManyField(blank=True, to="cinema.actor"),
91 | ),
92 | (
93 | "genres",
94 | models.ManyToManyField(blank=True, to="cinema.genre"),
95 | ),
96 | ],
97 | options={
98 | "ordering": ["title"],
99 | },
100 | ),
101 | migrations.CreateModel(
102 | name="MovieSession",
103 | fields=[
104 | (
105 | "id",
106 | models.BigAutoField(
107 | auto_created=True,
108 | primary_key=True,
109 | serialize=False,
110 | verbose_name="ID",
111 | ),
112 | ),
113 | ("show_time", models.DateTimeField()),
114 | (
115 | "cinema_hall",
116 | models.ForeignKey(
117 | on_delete=django.db.models.deletion.CASCADE,
118 | to="cinema.cinemahall",
119 | ),
120 | ),
121 | (
122 | "movie",
123 | models.ForeignKey(
124 | on_delete=django.db.models.deletion.CASCADE,
125 | to="cinema.movie",
126 | ),
127 | ),
128 | ],
129 | options={
130 | "ordering": ["-show_time"],
131 | },
132 | ),
133 | migrations.CreateModel(
134 | name="Order",
135 | fields=[
136 | (
137 | "id",
138 | models.BigAutoField(
139 | auto_created=True,
140 | primary_key=True,
141 | serialize=False,
142 | verbose_name="ID",
143 | ),
144 | ),
145 | ("created_at", models.DateTimeField(auto_now_add=True)),
146 | (
147 | "user",
148 | models.ForeignKey(
149 | on_delete=django.db.models.deletion.CASCADE,
150 | to=settings.AUTH_USER_MODEL,
151 | ),
152 | ),
153 | ],
154 | options={
155 | "ordering": ["-created_at"],
156 | },
157 | ),
158 | migrations.CreateModel(
159 | name="Ticket",
160 | fields=[
161 | (
162 | "id",
163 | models.BigAutoField(
164 | auto_created=True,
165 | primary_key=True,
166 | serialize=False,
167 | verbose_name="ID",
168 | ),
169 | ),
170 | ("row", models.IntegerField()),
171 | ("seat", models.IntegerField()),
172 | (
173 | "movie_session",
174 | models.ForeignKey(
175 | on_delete=django.db.models.deletion.CASCADE,
176 | related_name="tickets",
177 | to="cinema.moviesession",
178 | ),
179 | ),
180 | (
181 | "order",
182 | models.ForeignKey(
183 | on_delete=django.db.models.deletion.CASCADE,
184 | related_name="tickets",
185 | to="cinema.order",
186 | ),
187 | ),
188 | ],
189 | options={
190 | "ordering": ["row", "seat"],
191 | "unique_together": {("movie_session", "row", "seat")},
192 | },
193 | ),
194 | ]
195 |
--------------------------------------------------------------------------------
/frontend/src/views/MovieAddScreen.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
Please fill in the fields in details
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
Description
13 |
14 |
15 |
16 |
17 |
18 |
23 |
24 |
25 |
26 |
177 |
178 |
250 |
--------------------------------------------------------------------------------
/backend/cinema/views.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from django.db.models import F, Count
4 | from drf_spectacular.types import OpenApiTypes
5 | from drf_spectacular.utils import extend_schema, OpenApiParameter
6 | from rest_framework import viewsets, mixins, status
7 | from rest_framework.decorators import action
8 | from rest_framework.pagination import PageNumberPagination
9 | from rest_framework.permissions import IsAuthenticated, IsAdminUser
10 | from rest_framework.response import Response
11 | from rest_framework.viewsets import GenericViewSet
12 |
13 | from cinema.models import Genre, Actor, CinemaHall, Movie, MovieSession, Order
14 | from cinema.permissions import IsAdminOrIfAuthenticatedReadOnly
15 |
16 | from cinema.serializers import (
17 | GenreSerializer,
18 | ActorSerializer,
19 | CinemaHallSerializer,
20 | MovieSerializer,
21 | MovieSessionSerializer,
22 | MovieSessionListSerializer,
23 | MovieDetailSerializer,
24 | MovieSessionDetailSerializer,
25 | MovieListSerializer,
26 | OrderSerializer,
27 | OrderListSerializer,
28 | MovieImageSerializer,
29 | )
30 |
31 |
32 | class GenreViewSet(
33 | mixins.CreateModelMixin,
34 | mixins.ListModelMixin,
35 | GenericViewSet,
36 | ):
37 | queryset = Genre.objects.all()
38 | serializer_class = GenreSerializer
39 | permission_classes = (IsAdminOrIfAuthenticatedReadOnly,)
40 |
41 |
42 | class ActorViewSet(
43 | mixins.CreateModelMixin,
44 | mixins.ListModelMixin,
45 | GenericViewSet,
46 | ):
47 | queryset = Actor.objects.all()
48 | serializer_class = ActorSerializer
49 | permission_classes = (IsAdminOrIfAuthenticatedReadOnly,)
50 |
51 |
52 | class CinemaHallViewSet(
53 | mixins.CreateModelMixin,
54 | mixins.ListModelMixin,
55 | GenericViewSet,
56 | ):
57 | queryset = CinemaHall.objects.all()
58 | serializer_class = CinemaHallSerializer
59 | permission_classes = (IsAdminOrIfAuthenticatedReadOnly,)
60 |
61 |
62 | class MovieViewSet(
63 | mixins.ListModelMixin,
64 | mixins.CreateModelMixin,
65 | mixins.RetrieveModelMixin,
66 | viewsets.GenericViewSet,
67 | ):
68 | queryset = Movie.objects.prefetch_related("genres", "actors")
69 | serializer_class = MovieSerializer
70 | permission_classes = (IsAdminOrIfAuthenticatedReadOnly,)
71 |
72 | @staticmethod
73 | def _params_to_ints(qs):
74 | """Converts a list of string IDs to a list of integers"""
75 | return [int(str_id) for str_id in qs.split(",")]
76 |
77 | def get_queryset(self):
78 | """Retrieve the movies with filters"""
79 | title = self.request.query_params.get("title")
80 | genres = self.request.query_params.get("genres")
81 | actors = self.request.query_params.get("actors")
82 |
83 | queryset = self.queryset
84 |
85 | if title:
86 | queryset = queryset.filter(title__icontains=title)
87 |
88 | if genres:
89 | genres_ids = self._params_to_ints(genres)
90 | queryset = queryset.filter(genres__id__in=genres_ids)
91 |
92 | if actors:
93 | actors_ids = self._params_to_ints(actors)
94 | queryset = queryset.filter(actors__id__in=actors_ids)
95 |
96 | return queryset.distinct()
97 |
98 | def get_serializer_class(self):
99 | if self.action == "list":
100 | return MovieListSerializer
101 |
102 | if self.action == "retrieve":
103 | return MovieDetailSerializer
104 |
105 | if self.action == "upload_image":
106 | return MovieImageSerializer
107 |
108 | return MovieSerializer
109 |
110 | @action(
111 | methods=["POST"],
112 | detail=True,
113 | url_path="upload-image",
114 | permission_classes=[IsAdminUser],
115 | )
116 | def upload_image(self, request, pk=None):
117 | """Endpoint for uploading image to specific movie"""
118 | movie = self.get_object()
119 | serializer = self.get_serializer(movie, data=request.data)
120 |
121 | if serializer.is_valid():
122 | serializer.save()
123 | return Response(serializer.data, status=status.HTTP_200_OK)
124 |
125 | return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
126 |
127 | @extend_schema(
128 | parameters=[
129 | OpenApiParameter(
130 | "genres",
131 | type={"type": "list", "items": {"type": "number"}},
132 | description="Filter by genre id (ex. ?genres=2,5)",
133 | ),
134 | OpenApiParameter(
135 | "actors",
136 | type={"type": "list", "items": {"type": "number"}},
137 | description="Filter by actor id (ex. ?actors=2,5)",
138 | ),
139 | OpenApiParameter(
140 | "title",
141 | type=OpenApiTypes.STR,
142 | description="Filter by movie title (ex. ?title=fiction)",
143 | ),
144 | ]
145 | )
146 | def list(self, request, *args, **kwargs):
147 | return super().list(request, *args, **kwargs)
148 |
149 |
150 | class MovieSessionViewSet(viewsets.ModelViewSet):
151 | queryset = (
152 | MovieSession.objects.all()
153 | .select_related("movie", "cinema_hall")
154 | .annotate(
155 | tickets_available=(
156 | F("cinema_hall__rows") * F("cinema_hall__seats_in_row")
157 | - Count("tickets")
158 | )
159 | )
160 | )
161 | serializer_class = MovieSessionSerializer
162 | permission_classes = (IsAdminOrIfAuthenticatedReadOnly,)
163 |
164 | def get_queryset(self):
165 | date = self.request.query_params.get("date")
166 | movie_id_str = self.request.query_params.get("movie")
167 |
168 | queryset = self.queryset
169 |
170 | if date:
171 | date = datetime.strptime(date, "%Y-%m-%d").date()
172 | queryset = queryset.filter(show_time__date=date)
173 |
174 | if movie_id_str:
175 | queryset = queryset.filter(movie_id=int(movie_id_str))
176 |
177 | return queryset
178 |
179 | def get_serializer_class(self):
180 | if self.action == "list":
181 | return MovieSessionListSerializer
182 |
183 | if self.action == "retrieve":
184 | return MovieSessionDetailSerializer
185 |
186 | return MovieSessionSerializer
187 |
188 | @extend_schema(
189 | parameters=[
190 | OpenApiParameter(
191 | "movie",
192 | type=OpenApiTypes.INT,
193 | description="Filter by movie id (ex. ?movie=2)",
194 | ),
195 | OpenApiParameter(
196 | "date",
197 | type=OpenApiTypes.DATE,
198 | description=(
199 | "Filter by datetime of MovieSession "
200 | "(ex. ?date=2022-10-23)"
201 | ),
202 | ),
203 | ]
204 | )
205 | def list(self, request, *args, **kwargs):
206 | return super().list(request, *args, **kwargs)
207 |
208 |
209 | class OrderPagination(PageNumberPagination):
210 | page_size = 10
211 | max_page_size = 100
212 |
213 |
214 | class OrderViewSet(
215 | mixins.ListModelMixin,
216 | mixins.CreateModelMixin,
217 | GenericViewSet,
218 | ):
219 | queryset = Order.objects.prefetch_related(
220 | "tickets__movie_session__movie", "tickets__movie_session__cinema_hall"
221 | )
222 | serializer_class = OrderSerializer
223 | pagination_class = OrderPagination
224 | permission_classes = (IsAuthenticated,)
225 |
226 | def get_queryset(self):
227 | return Order.objects.filter(user=self.request.user)
228 |
229 | def get_serializer_class(self):
230 | if self.action == "list":
231 | return OrderListSerializer
232 |
233 | return OrderSerializer
234 |
235 | def perform_create(self, serializer):
236 | serializer.save(user=self.request.user)
237 |
--------------------------------------------------------------------------------
/sample_cinema.json:
--------------------------------------------------------------------------------
1 | [{"model": "admin.logentry", "pk": 1, "fields": {"action_time": "2023-01-24T09:52:42.129", "user": 1, "content_type": 9, "object_id": "3", "object_repr": "Ace Ventura", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"Title\", \"Image\"]}}]"}}, {"model": "admin.logentry", "pk": 2, "fields": {"action_time": "2023-01-24T09:53:29.034", "user": 1, "content_type": 9, "object_id": "4", "object_repr": "Liar Liar", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"Image\"]}}]"}}, {"model": "admin.logentry", "pk": 3, "fields": {"action_time": "2023-01-24T09:53:34.618", "user": 1, "content_type": 9, "object_id": "1", "object_repr": "Spider Man: No Way Home", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"Image\"]}}]"}}, {"model": "admin.logentry", "pk": 4, "fields": {"action_time": "2023-01-24T09:53:42.039", "user": 1, "content_type": 9, "object_id": "2", "object_repr": "Titanic", "action_flag": 2, "change_message": "[{\"changed\": {\"fields\": [\"Image\"]}}]"}}, {"model": "auth.permission", "pk": 1, "fields": {"name": "Can add log entry", "content_type": 1, "codename": "add_logentry"}}, {"model": "auth.permission", "pk": 2, "fields": {"name": "Can change log entry", "content_type": 1, "codename": "change_logentry"}}, {"model": "auth.permission", "pk": 3, "fields": {"name": "Can delete log entry", "content_type": 1, "codename": "delete_logentry"}}, {"model": "auth.permission", "pk": 4, "fields": {"name": "Can view log entry", "content_type": 1, "codename": "view_logentry"}}, {"model": "auth.permission", "pk": 5, "fields": {"name": "Can add permission", "content_type": 2, "codename": "add_permission"}}, {"model": "auth.permission", "pk": 6, "fields": {"name": "Can change permission", "content_type": 2, "codename": "change_permission"}}, {"model": "auth.permission", "pk": 7, "fields": {"name": "Can delete permission", "content_type": 2, "codename": "delete_permission"}}, {"model": "auth.permission", "pk": 8, "fields": {"name": "Can view permission", "content_type": 2, "codename": "view_permission"}}, {"model": "auth.permission", "pk": 9, "fields": {"name": "Can add group", "content_type": 3, "codename": "add_group"}}, {"model": "auth.permission", "pk": 10, "fields": {"name": "Can change group", "content_type": 3, "codename": "change_group"}}, {"model": "auth.permission", "pk": 11, "fields": {"name": "Can delete group", "content_type": 3, "codename": "delete_group"}}, {"model": "auth.permission", "pk": 12, "fields": {"name": "Can view group", "content_type": 3, "codename": "view_group"}}, {"model": "auth.permission", "pk": 13, "fields": {"name": "Can add content type", "content_type": 4, "codename": "add_contenttype"}}, {"model": "auth.permission", "pk": 14, "fields": {"name": "Can change content type", "content_type": 4, "codename": "change_contenttype"}}, {"model": "auth.permission", "pk": 15, "fields": {"name": "Can delete content type", "content_type": 4, "codename": "delete_contenttype"}}, {"model": "auth.permission", "pk": 16, "fields": {"name": "Can view content type", "content_type": 4, "codename": "view_contenttype"}}, {"model": "auth.permission", "pk": 17, "fields": {"name": "Can add session", "content_type": 5, "codename": "add_session"}}, {"model": "auth.permission", "pk": 18, "fields": {"name": "Can change session", "content_type": 5, "codename": "change_session"}}, {"model": "auth.permission", "pk": 19, "fields": {"name": "Can delete session", "content_type": 5, "codename": "delete_session"}}, {"model": "auth.permission", "pk": 20, "fields": {"name": "Can view session", "content_type": 5, "codename": "view_session"}}, {"model": "auth.permission", "pk": 21, "fields": {"name": "Can add actor", "content_type": 6, "codename": "add_actor"}}, {"model": "auth.permission", "pk": 22, "fields": {"name": "Can change actor", "content_type": 6, "codename": "change_actor"}}, {"model": "auth.permission", "pk": 23, "fields": {"name": "Can delete actor", "content_type": 6, "codename": "delete_actor"}}, {"model": "auth.permission", "pk": 24, "fields": {"name": "Can view actor", "content_type": 6, "codename": "view_actor"}}, {"model": "auth.permission", "pk": 25, "fields": {"name": "Can add cinema hall", "content_type": 7, "codename": "add_cinemahall"}}, {"model": "auth.permission", "pk": 26, "fields": {"name": "Can change cinema hall", "content_type": 7, "codename": "change_cinemahall"}}, {"model": "auth.permission", "pk": 27, "fields": {"name": "Can delete cinema hall", "content_type": 7, "codename": "delete_cinemahall"}}, {"model": "auth.permission", "pk": 28, "fields": {"name": "Can view cinema hall", "content_type": 7, "codename": "view_cinemahall"}}, {"model": "auth.permission", "pk": 29, "fields": {"name": "Can add genre", "content_type": 8, "codename": "add_genre"}}, {"model": "auth.permission", "pk": 30, "fields": {"name": "Can change genre", "content_type": 8, "codename": "change_genre"}}, {"model": "auth.permission", "pk": 31, "fields": {"name": "Can delete genre", "content_type": 8, "codename": "delete_genre"}}, {"model": "auth.permission", "pk": 32, "fields": {"name": "Can view genre", "content_type": 8, "codename": "view_genre"}}, {"model": "auth.permission", "pk": 33, "fields": {"name": "Can add movie", "content_type": 9, "codename": "add_movie"}}, {"model": "auth.permission", "pk": 34, "fields": {"name": "Can change movie", "content_type": 9, "codename": "change_movie"}}, {"model": "auth.permission", "pk": 35, "fields": {"name": "Can delete movie", "content_type": 9, "codename": "delete_movie"}}, {"model": "auth.permission", "pk": 36, "fields": {"name": "Can view movie", "content_type": 9, "codename": "view_movie"}}, {"model": "auth.permission", "pk": 37, "fields": {"name": "Can add movie session", "content_type": 10, "codename": "add_moviesession"}}, {"model": "auth.permission", "pk": 38, "fields": {"name": "Can change movie session", "content_type": 10, "codename": "change_moviesession"}}, {"model": "auth.permission", "pk": 39, "fields": {"name": "Can delete movie session", "content_type": 10, "codename": "delete_moviesession"}}, {"model": "auth.permission", "pk": 40, "fields": {"name": "Can view movie session", "content_type": 10, "codename": "view_moviesession"}}, {"model": "auth.permission", "pk": 41, "fields": {"name": "Can add order", "content_type": 11, "codename": "add_order"}}, {"model": "auth.permission", "pk": 42, "fields": {"name": "Can change order", "content_type": 11, "codename": "change_order"}}, {"model": "auth.permission", "pk": 43, "fields": {"name": "Can delete order", "content_type": 11, "codename": "delete_order"}}, {"model": "auth.permission", "pk": 44, "fields": {"name": "Can view order", "content_type": 11, "codename": "view_order"}}, {"model": "auth.permission", "pk": 45, "fields": {"name": "Can add ticket", "content_type": 12, "codename": "add_ticket"}}, {"model": "auth.permission", "pk": 46, "fields": {"name": "Can change ticket", "content_type": 12, "codename": "change_ticket"}}, {"model": "auth.permission", "pk": 47, "fields": {"name": "Can delete ticket", "content_type": 12, "codename": "delete_ticket"}}, {"model": "auth.permission", "pk": 48, "fields": {"name": "Can view ticket", "content_type": 12, "codename": "view_ticket"}}, {"model": "auth.permission", "pk": 49, "fields": {"name": "Can add user", "content_type": 13, "codename": "add_user"}}, {"model": "auth.permission", "pk": 50, "fields": {"name": "Can change user", "content_type": 13, "codename": "change_user"}}, {"model": "auth.permission", "pk": 51, "fields": {"name": "Can delete user", "content_type": 13, "codename": "delete_user"}}, {"model": "auth.permission", "pk": 52, "fields": {"name": "Can view user", "content_type": 13, "codename": "view_user"}}, {"model": "contenttypes.contenttype", "pk": 1, "fields": {"app_label": "admin", "model": "logentry"}}, {"model": "contenttypes.contenttype", "pk": 2, "fields": {"app_label": "auth", "model": "permission"}}, {"model": "contenttypes.contenttype", "pk": 3, "fields": {"app_label": "auth", "model": "group"}}, {"model": "contenttypes.contenttype", "pk": 4, "fields": {"app_label": "contenttypes", "model": "contenttype"}}, {"model": "contenttypes.contenttype", "pk": 5, "fields": {"app_label": "sessions", "model": "session"}}, {"model": "contenttypes.contenttype", "pk": 6, "fields": {"app_label": "cinema", "model": "actor"}}, {"model": "contenttypes.contenttype", "pk": 7, "fields": {"app_label": "cinema", "model": "cinemahall"}}, {"model": "contenttypes.contenttype", "pk": 8, "fields": {"app_label": "cinema", "model": "genre"}}, {"model": "contenttypes.contenttype", "pk": 9, "fields": {"app_label": "cinema", "model": "movie"}}, {"model": "contenttypes.contenttype", "pk": 10, "fields": {"app_label": "cinema", "model": "moviesession"}}, {"model": "contenttypes.contenttype", "pk": 11, "fields": {"app_label": "cinema", "model": "order"}}, {"model": "contenttypes.contenttype", "pk": 12, "fields": {"app_label": "cinema", "model": "ticket"}}, {"model": "contenttypes.contenttype", "pk": 13, "fields": {"app_label": "user", "model": "user"}}, {"model": "sessions.session", "pk": "gwcu38uhf30s8y1tw4mtie7bo4mca5ci", "fields": {"session_data": ".eJxVjDEOwjAMRe-SGUWxC5HLyM4ZIjuxSQG1UtNOiLtDpQ6w_vfef7nE61LT2nROQ3FnB-7wuwnnh44bKHceb5PP07jMg_hN8Ttt_joVfV529--gcqvfuu-AQDmSZRA-qTBaIDsaEkQ1FIxcDJDIwEQx9lRy1hhQuhxDce8P_Xs4vQ:1pKFyU:2IHrtRhfyWK5yiHfsMGm98Pk-03sg-T_Ea0mkPVZulg", "expire_date": "2023-02-07T09:52:18.894"}}, {"model": "sessions.session", "pk": "tpe5st1krw0y4z3z36wrqanew9ungr70", "fields": {"session_data": ".eJxVjDEOwjAMRe-SGUWxC5HLyM4ZIjuxSQG1UtNOiLtDpQ6w_vfef7nE61LT2nROQ3FnB-7wuwnnh44bKHceb5PP07jMg_hN8Ttt_joVfV529--gcqvfuu-AQDmSZRA-qTBaIDsaEkQ1FIxcDJDIwEQx9lRy1hhQuhxDce8P_Xs4vQ:1pKFyT:TN8XYpG2jmwOjjY66p-hEJv6ypladTSUN2ZLyAOiuv0", "expire_date": "2023-02-07T09:52:17.410"}}, {"model": "cinema.cinemahall", "pk": 1, "fields": {"name": "Red", "rows": 6, "seats_in_row": 8}}, {"model": "cinema.cinemahall", "pk": 2, "fields": {"name": "Green", "rows": 8, "seats_in_row": 9}}, {"model": "cinema.cinemahall", "pk": 3, "fields": {"name": "VIP", "rows": 5, "seats_in_row": 6}}, {"model": "cinema.genre", "pk": 1, "fields": {"name": "drama"}}, {"model": "cinema.genre", "pk": 2, "fields": {"name": "comedy"}}, {"model": "cinema.genre", "pk": 3, "fields": {"name": "fantasy"}}, {"model": "cinema.genre", "pk": 4, "fields": {"name": "romance"}}, {"model": "cinema.actor", "pk": 1, "fields": {"first_name": "Jim", "last_name": "Carrey"}}, {"model": "cinema.actor", "pk": 2, "fields": {"first_name": "Tom", "last_name": "Holland"}}, {"model": "cinema.actor", "pk": 3, "fields": {"first_name": "Leonardo", "last_name": "DiCaprio"}}, {"model": "cinema.actor", "pk": 4, "fields": {"first_name": "Kate", "last_name": "Winslet"}}, {"model": "cinema.movie", "pk": 1, "fields": {"title": "Spider Man: No Way Home", "description": "With Spider-Man's identity now revealed, Peter asks Doctor Strange for help. When a spell goes wrong, dangerous foes from other worlds start to appear, forcing Peter to discover what it truly means to be Spider-Man.", "duration": 148, "image": "uploads/movies/spider-man-no-way-home-a3bc44fc-5f63-44c4-ba89-36b60f4922dc.jpeg", "genres": [3], "actors": [2]}}, {"model": "cinema.movie", "pk": 2, "fields": {"title": "Titanic", "description": "A seventeen-year-old aristocrat falls in love with a kind but poor artist aboard the luxurious, ill-fated R.M.S. Titanic.", "duration": 194, "image": "uploads/movies/titanic-3b1abe14-b77a-4a44-837c-1b3ae7a2d695.jpeg", "genres": [1, 4], "actors": [3, 4]}}, {"model": "cinema.movie", "pk": 3, "fields": {"title": "Ace Ventura", "description": "A goofy detective specializing in animals goes in search of the missing mascot of the Miami Dolphins.", "duration": 86, "image": "uploads/movies/ace-ventura-37b4168c-9ad0-4a45-b06a-cb0a985f6b5d.jpg", "genres": [2], "actors": [1]}}, {"model": "cinema.movie", "pk": 4, "fields": {"title": "Liar Liar", "description": "A pathological liar-lawyer finds his career turned upside down when he inexplicably cannot physically lie for 24 whole hours.", "duration": 86, "image": "uploads/movies/liar-liar-e71fb71d-eca9-4ec2-a3e2-cc8a1e797a4b.jpeg", "genres": [2], "actors": [1]}}, {"model": "cinema.moviesession", "pk": 1, "fields": {"show_time": "2023-01-24T18:00:00", "movie": 3, "cinema_hall": 1}}, {"model": "user.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$320000$tn2DBjAo8FhGtvVfpB1FXf$Lu0+qoZtp4clRlxrvw+UeB3yk0A8BQY9Xxam5e0sJj8=", "last_login": "2023-01-24T09:52:18.890", "is_superuser": true, "first_name": "", "last_name": "", "is_staff": true, "is_active": true, "date_joined": "2023-01-24T09:39:39.042", "email": "admin@admin.com", "groups": [], "user_permissions": []}}]
--------------------------------------------------------------------------------
/backend/cinema/tests/test_movie_api.py:
--------------------------------------------------------------------------------
1 | import tempfile
2 | import os
3 |
4 | from PIL import Image
5 | from django.contrib.auth import get_user_model
6 | from django.test import TestCase
7 | from django.urls import reverse
8 |
9 | from rest_framework.test import APIClient
10 | from rest_framework import status
11 |
12 | from cinema.models import Movie, MovieSession, CinemaHall, Genre, Actor
13 | from cinema.serializers import MovieListSerializer, MovieDetailSerializer
14 |
15 | MOVIE_URL = reverse("cinema:movie-list")
16 | MOVIE_SESSION_URL = reverse("cinema:moviesession-list")
17 |
18 |
19 | def sample_movie(**params):
20 | defaults = {
21 | "title": "Sample movie",
22 | "description": "Sample description",
23 | "duration": 90,
24 | }
25 | defaults.update(params)
26 |
27 | return Movie.objects.create(**defaults)
28 |
29 |
30 | def sample_movie_session(**params):
31 | cinema_hall = CinemaHall.objects.create(
32 | name="Blue", rows=20, seats_in_row=20
33 | )
34 |
35 | defaults = {
36 | "show_time": "2022-06-02 14:00:00",
37 | "movie": None,
38 | "cinema_hall": cinema_hall,
39 | }
40 | defaults.update(params)
41 |
42 | return MovieSession.objects.create(**defaults)
43 |
44 |
45 | def image_upload_url(movie_id):
46 | """Return URL for recipe image upload"""
47 | return reverse("cinema:movie-upload-image", args=[movie_id])
48 |
49 |
50 | def detail_url(movie_id):
51 | return reverse("cinema:movie-detail", args=[movie_id])
52 |
53 |
54 | class UnauthenticatedMovieApiTests(TestCase):
55 | def setUp(self):
56 | self.client = APIClient()
57 |
58 | def test_auth_required(self):
59 | res = self.client.get(MOVIE_URL)
60 | self.assertEqual(res.status_code, status.HTTP_401_UNAUTHORIZED)
61 |
62 |
63 | class AuthenticatedMovieApiTests(TestCase):
64 | def setUp(self):
65 | self.client = APIClient()
66 | self.user = get_user_model().objects.create_user(
67 | "test@test.com",
68 | "testpass",
69 | )
70 | self.client.force_authenticate(self.user)
71 |
72 | def test_list_movies(self):
73 | sample_movie()
74 | sample_movie()
75 |
76 | res = self.client.get(MOVIE_URL)
77 |
78 | movies = Movie.objects.order_by("id")
79 | serializer = MovieListSerializer(movies, many=True)
80 |
81 | self.assertEqual(res.status_code, status.HTTP_200_OK)
82 | self.assertEqual(res.data, serializer.data)
83 |
84 | def test_filter_movies_by_genres(self):
85 | genre1 = Genre.objects.create(name="Genre 1")
86 | genre2 = Genre.objects.create(name="Genre 2")
87 |
88 | movie1 = sample_movie(title="Movie 1")
89 | movie2 = sample_movie(title="Movie 2")
90 |
91 | movie1.genres.add(genre1)
92 | movie2.genres.add(genre2)
93 |
94 | movie3 = sample_movie(title="Movie without genres")
95 |
96 | res = self.client.get(
97 | MOVIE_URL, {"genres": f"{genre1.id},{genre2.id}"}
98 | )
99 |
100 | serializer1 = MovieListSerializer(movie1)
101 | serializer2 = MovieListSerializer(movie2)
102 | serializer3 = MovieListSerializer(movie3)
103 |
104 | self.assertIn(serializer1.data, res.data)
105 | self.assertIn(serializer2.data, res.data)
106 | self.assertNotIn(serializer3.data, res.data)
107 |
108 | def test_filter_movies_by_actors(self):
109 | actor1 = Actor.objects.create(first_name="Actor 1", last_name="Last 1")
110 | actor2 = Actor.objects.create(first_name="Actor 2", last_name="Last 2")
111 |
112 | movie1 = sample_movie(title="Movie 1")
113 | movie2 = sample_movie(title="Movie 2")
114 |
115 | movie1.actors.add(actor1)
116 | movie2.actors.add(actor2)
117 |
118 | movie3 = sample_movie(title="Movie without actors")
119 |
120 | res = self.client.get(
121 | MOVIE_URL, {"actors": f"{actor1.id},{actor2.id}"}
122 | )
123 |
124 | serializer1 = MovieListSerializer(movie1)
125 | serializer2 = MovieListSerializer(movie2)
126 | serializer3 = MovieListSerializer(movie3)
127 |
128 | self.assertIn(serializer1.data, res.data)
129 | self.assertIn(serializer2.data, res.data)
130 | self.assertNotIn(serializer3.data, res.data)
131 |
132 | def test_filter_movies_by_title(self):
133 | movie1 = sample_movie(title="Movie")
134 | movie2 = sample_movie(title="Another Movie")
135 | movie3 = sample_movie(title="No match")
136 |
137 | res = self.client.get(MOVIE_URL, {"title": "movie"})
138 |
139 | serializer1 = MovieListSerializer(movie1)
140 | serializer2 = MovieListSerializer(movie2)
141 | serializer3 = MovieListSerializer(movie3)
142 |
143 | self.assertIn(serializer1.data, res.data)
144 | self.assertIn(serializer2.data, res.data)
145 | self.assertNotIn(serializer3.data, res.data)
146 |
147 | def test_retrieve_movie_detail(self):
148 | movie = sample_movie()
149 | movie.genres.add(Genre.objects.create(name="Genre"))
150 | movie.actors.add(
151 | Actor.objects.create(first_name="Actor", last_name="Last")
152 | )
153 |
154 | url = detail_url(movie.id)
155 | res = self.client.get(url)
156 |
157 | serializer = MovieDetailSerializer(movie)
158 | self.assertEqual(res.status_code, status.HTTP_200_OK)
159 | self.assertEqual(res.data, serializer.data)
160 |
161 | def test_create_movie_forbidden(self):
162 | payload = {
163 | "title": "Movie",
164 | "description": "Description",
165 | "duration": 90,
166 | }
167 | res = self.client.post(MOVIE_URL, payload)
168 |
169 | self.assertEqual(res.status_code, status.HTTP_403_FORBIDDEN)
170 |
171 |
172 | class AdminMovieApiTests(TestCase):
173 | def setUp(self):
174 | self.client = APIClient()
175 | self.user = get_user_model().objects.create_user(
176 | "admin@admin.com", "testpass", is_staff=True
177 | )
178 | self.client.force_authenticate(self.user)
179 |
180 | def test_create_movie(self):
181 | payload = {
182 | "title": "Movie",
183 | "description": "Description",
184 | "duration": 90,
185 | }
186 | res = self.client.post(MOVIE_URL, payload)
187 |
188 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
189 | movie = Movie.objects.get(id=res.data["id"])
190 | for key in payload.keys():
191 | self.assertEqual(payload[key], getattr(movie, key))
192 |
193 | def test_create_movie_with_genres(self):
194 | genre1 = Genre.objects.create(name="Action")
195 | genre2 = Genre.objects.create(name="Adventure")
196 | payload = {
197 | "title": "Spider Man",
198 | "genres": [genre1.id, genre2.id],
199 | "description": "With Spider-Man's identity now revealed, Peter asks Doctor Strange for help.",
200 | "duration": 148,
201 | }
202 | res = self.client.post(MOVIE_URL, payload)
203 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
204 |
205 | movie = Movie.objects.get(id=res.data["id"])
206 | genres = movie.genres.all()
207 | self.assertEqual(genres.count(), 2)
208 | self.assertIn(genre1, genres)
209 | self.assertIn(genre2, genres)
210 |
211 | def test_create_movie_with_actors(self):
212 | actor1 = Actor.objects.create(first_name="Tom", last_name="Holland")
213 | actor2 = Actor.objects.create(first_name="Tobey", last_name="Maguire")
214 | payload = {
215 | "title": "Spider Man",
216 | "actors": [actor1.id, actor2.id],
217 | "description": "With Spider-Man's identity now revealed, Peter asks Doctor Strange for help.",
218 | "duration": 148,
219 | }
220 | res = self.client.post(MOVIE_URL, payload)
221 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
222 |
223 | movie = Movie.objects.get(id=res.data["id"])
224 | actors = movie.actors.all()
225 | self.assertEqual(actors.count(), 2)
226 | self.assertIn(actor1, actors)
227 | self.assertIn(actor2, actors)
228 |
229 |
230 | class MovieImageUploadTests(TestCase):
231 | def setUp(self):
232 | self.client = APIClient()
233 | self.user = get_user_model().objects.create_superuser(
234 | "admin@myproject.com", "password"
235 | )
236 | self.client.force_authenticate(self.user)
237 | self.movie = sample_movie()
238 | self.movie_session = sample_movie_session(movie=self.movie)
239 |
240 | def tearDown(self):
241 | self.movie.image.delete()
242 |
243 | def test_upload_image_to_movie(self):
244 | """Test uploading an image to movie"""
245 | url = image_upload_url(self.movie.id)
246 | with tempfile.NamedTemporaryFile(suffix=".jpg") as ntf:
247 | img = Image.new("RGB", (10, 10))
248 | img.save(ntf, format="JPEG")
249 | ntf.seek(0)
250 | res = self.client.post(url, {"image": ntf}, format="multipart")
251 | self.movie.refresh_from_db()
252 |
253 | self.assertEqual(res.status_code, status.HTTP_200_OK)
254 | self.assertIn("image", res.data)
255 | self.assertTrue(os.path.exists(self.movie.image.path))
256 |
257 | def test_upload_image_bad_request(self):
258 | """Test uploading an invalid image"""
259 | url = image_upload_url(self.movie.id)
260 | res = self.client.post(url, {"image": "not image"}, format="multipart")
261 |
262 | self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST)
263 |
264 | def test_post_image_to_movie_list_should_not_work(self):
265 | url = MOVIE_URL
266 | with tempfile.NamedTemporaryFile(suffix=".jpg") as ntf:
267 | img = Image.new("RGB", (10, 10))
268 | img.save(ntf, format="JPEG")
269 | ntf.seek(0)
270 | res = self.client.post(
271 | url,
272 | {
273 | "title": "Title",
274 | "description": "Description",
275 | "duration": 90,
276 | "image": ntf,
277 | },
278 | format="multipart",
279 | )
280 |
281 | self.assertEqual(res.status_code, status.HTTP_201_CREATED)
282 | movie = Movie.objects.get(title="Title")
283 | self.assertFalse(movie.image)
284 |
285 | def test_image_url_is_shown_on_movie_detail(self):
286 | url = image_upload_url(self.movie.id)
287 | with tempfile.NamedTemporaryFile(suffix=".jpg") as ntf:
288 | img = Image.new("RGB", (10, 10))
289 | img.save(ntf, format="JPEG")
290 | ntf.seek(0)
291 | self.client.post(url, {"image": ntf}, format="multipart")
292 | res = self.client.get(detail_url(self.movie.id))
293 |
294 | self.assertIn("image", res.data)
295 |
296 | def test_image_url_is_shown_on_movie_list(self):
297 | url = image_upload_url(self.movie.id)
298 | with tempfile.NamedTemporaryFile(suffix=".jpg") as ntf:
299 | img = Image.new("RGB", (10, 10))
300 | img.save(ntf, format="JPEG")
301 | ntf.seek(0)
302 | self.client.post(url, {"image": ntf}, format="multipart")
303 | res = self.client.get(MOVIE_URL)
304 |
305 | self.assertIn("image", res.data[0].keys())
306 |
307 | def test_image_url_is_shown_on_movie_session_detail(self):
308 | url = image_upload_url(self.movie.id)
309 | with tempfile.NamedTemporaryFile(suffix=".jpg") as ntf:
310 | img = Image.new("RGB", (10, 10))
311 | img.save(ntf, format="JPEG")
312 | ntf.seek(0)
313 | self.client.post(url, {"image": ntf}, format="multipart")
314 | res = self.client.get(MOVIE_SESSION_URL)
315 |
316 | self.assertIn("movie_image", res.data[0].keys())
317 |
318 | def test_put_movie_not_allowed(self):
319 | payload = {
320 | "title": "New movie",
321 | "description": "New description",
322 | "duration": 98,
323 | }
324 |
325 | movie = sample_movie()
326 | url = detail_url(movie.id)
327 |
328 | res = self.client.put(url, payload)
329 |
330 | self.assertEqual(res.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
331 |
332 | def test_delete_movie_not_allowed(self):
333 | movie = sample_movie()
334 | url = detail_url(movie.id)
335 |
336 | res = self.client.delete(url)
337 |
338 | self.assertEqual(res.status_code, status.HTTP_405_METHOD_NOT_ALLOWED)
339 |
--------------------------------------------------------------------------------