├── 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 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/arrow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/cross.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | 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 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | success-standard-solid -------------------------------------------------------------------------------- /frontend/src/comps/HeaderPopup.vue: -------------------------------------------------------------------------------- 1 | 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 | error-standard-solid -------------------------------------------------------------------------------- /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 | 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 | 11 | 12 | 27 | 28 | 48 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/clock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | 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 | 12 | 13 | 44 | 45 | 81 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/eye_crossed.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /frontend/src/comps/TimePicker.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 38 | 39 | 81 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/eye.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /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 | 20 | 21 | 41 | 42 | 84 | -------------------------------------------------------------------------------- /frontend/src/views/MovieDetailsScreen.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 70 | 71 | 84 | -------------------------------------------------------------------------------- /frontend/src/comps/InputItem.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 61 | 62 | 117 | -------------------------------------------------------------------------------- /frontend/src/views/SignIn.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 65 | 66 | 101 | -------------------------------------------------------------------------------- /frontend/src/comps/MovieModal.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 37 | 38 | 115 | -------------------------------------------------------------------------------- /frontend/src/comps/PasswordInput.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 57 | 58 | 113 | -------------------------------------------------------------------------------- /frontend/src/views/SignUp.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 70 | 71 | 106 | -------------------------------------------------------------------------------- /frontend/src/views/CinemaHallListScreen.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 74 | 75 | 119 | -------------------------------------------------------------------------------- /frontend/src/views/CinemaHallAddScreen.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 84 | 85 | 121 | -------------------------------------------------------------------------------- /frontend/src/comps/MovieCard.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 65 | 66 | 129 | -------------------------------------------------------------------------------- /frontend/public/assets/icons/cart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /frontend/src/views/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 78 | 79 | 142 | -------------------------------------------------------------------------------- /frontend/src/views/ProfileScreen.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 109 | 110 | 136 | -------------------------------------------------------------------------------- /frontend/src/comps/CustomSelect.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 68 | 69 | 154 | -------------------------------------------------------------------------------- /frontend/src/comps/CustomMultiselect.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 66 | 67 | 152 | -------------------------------------------------------------------------------- /frontend/src/views/GenreListScreen.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 109 | 110 | 157 | -------------------------------------------------------------------------------- /frontend/src/comps/CinemaHallSchema.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 79 | 80 | 154 | -------------------------------------------------------------------------------- /frontend/src/views/MovieSessionListScreen.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 123 | 124 | 151 | -------------------------------------------------------------------------------- /frontend/src/views/ActorListScreen.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 116 | 117 | 169 | -------------------------------------------------------------------------------- /frontend/src/views/MovieSessionDetailsScreen.vue: -------------------------------------------------------------------------------- 1 | 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 | 21 | 22 | 132 | 133 | 146 | -------------------------------------------------------------------------------- /frontend/src/views/MovieSessionAddScreen.vue: -------------------------------------------------------------------------------- 1 | 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 | 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 | 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 | 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 | --------------------------------------------------------------------------------