diff --git a/.env.example b/.env.example index 8a2d2bb..128f832 100644 --- a/.env.example +++ b/.env.example @@ -11,5 +11,10 @@ DATABASE_HOST=movienight-db DATABASE_NAME=movienight DATABASE_USERNAME=admin DATABASE_PASSWORD=super_secret_password + +# Django key generator: https://djecrety.ir/ SECRET_KEY=your_django_secret_key DJANGO_SECRET_KEY=your_django_secret_key + +# You can get a free key here: https://www.omdbapi.com/apikey.aspx +OMDB_API_KEY=your_omdb_api_key diff --git a/.gitignore b/.gitignore index 2ae60fe..41818d1 100644 --- a/.gitignore +++ b/.gitignore @@ -173,5 +173,8 @@ cython_debug/ # PyPI configuration file .pypirc -# Django +# django static + +# JetBrains +.idea diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 9f60641..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 60235a5..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/movie-night-py.iml b/.idea/movie-night-py.iml deleted file mode 100644 index f13645b..0000000 --- a/.idea/movie-night-py.iml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e94a7e1..e6d5f42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,4 +35,4 @@ USER web EXPOSE 8000 -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "3", "movienight.wsgi:application"] +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 34ef508..20f5b0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,15 +5,18 @@ services: environment: POSTGRES_DB: ${DATABASE_NAME} POSTGRES_USER: ${DATABASE_USERNAME} + PGUSER: ${DATABASE_USERNAME} POSTGRES_PASSWORD: ${DATABASE_PASSWORD} env_file: - .env ports: - "5432:5432" + user: postgres volumes: - movienight_data:/var/lib/postgresql/data + - ./init-scripts:/docker-entrypoint-initdb.d healthcheck: - test: ["CMD-SHELL", "pg_isready", "-d", "$DATABASE_NAME"] + test: ["CMD-SHELL", "pg_isready -U $DATABASE_USERNAME"] interval: 30s timeout: 60s retries: 5 @@ -40,6 +43,7 @@ services: DATABASE_PASSWORD: ${DATABASE_PASSWORD} DATABASE_HOST: ${DATABASE_HOST} DATABASE_PORT: ${DATABASE_PORT} + OMDB_API_KEY: ${OMDB_API_KEY} env_file: - .env volumes: diff --git a/init-scripts/01-create-db.sh b/init-scripts/01-create-db.sh new file mode 100644 index 0000000..13d3573 --- /dev/null +++ b/init-scripts/01-create-db.sh @@ -0,0 +1,10 @@ +#!/bin/bash +set -e + +# Create a postgres database for the user set in the .env file. +# Everything works fine without this, but this prevents a FATAL +# error from spamming the logs +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE DATABASE "$POSTGRES_USER"; + GRANT ALL PRIVILEGES ON DATABASE "$POSTGRES_USER" TO "$POSTGRES_USER"; +EOSQL \ No newline at end of file diff --git a/movie_db/__init__.py b/movie_db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/movie_db/admin.py b/movie_db/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/movie_db/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/movie_db/apps.py b/movie_db/apps.py new file mode 100644 index 0000000..d9aa88e --- /dev/null +++ b/movie_db/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class MoviedbConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'movie_db' diff --git a/movie_db/db_providers/omdb.py b/movie_db/db_providers/omdb.py new file mode 100644 index 0000000..2041126 --- /dev/null +++ b/movie_db/db_providers/omdb.py @@ -0,0 +1,38 @@ +import os + +from django.conf import settings + +from movie_db.movie_db import MovieDB +import requests + +from movie_db.serializers import MovieSerializer, MovieResultSerializer + + +class OMDb(MovieDB): + def __init__(self): + self.api_key = settings.OMDB_API_KEY + self.base_url = "https://www.omdbapi.com/?apikey=" + self.api_key + super().__init__() + + def search(self, query, options=None): + if options["type"] == "imdb_id": + return self.search_by_imdb_id(query) + elif options["type"] == "title": + return self.search_by_title(query) + else: + return self.search_by_term(query) + + def search_by_title(self, title): + response = requests.get(self.base_url + "&t=" + title).json() + return MovieSerializer(response).data + + def search_by_imdb_id(self, imdb_id): + response = requests.get(self.base_url + "&i=" + imdb_id).json() + return MovieSerializer(response).data + + def search_by_term(self, term): + response = requests.get(self.base_url + "&s=" + term).json() + try: + return MovieResultSerializer(response["Search"], many=True).data + except KeyError: + return {"error": response} diff --git a/movie_db/migrations/__init__.py b/movie_db/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/movie_db/models.py b/movie_db/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/movie_db/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/movie_db/movie_db.py b/movie_db/movie_db.py new file mode 100644 index 0000000..d65a019 --- /dev/null +++ b/movie_db/movie_db.py @@ -0,0 +1,8 @@ +from abc import ABC + +class MovieDB(ABC): + def __init__(self): + pass + + def search(self, query, options=None): + pass \ No newline at end of file diff --git a/movie_db/serializers.py b/movie_db/serializers.py new file mode 100644 index 0000000..077d771 --- /dev/null +++ b/movie_db/serializers.py @@ -0,0 +1,24 @@ +from rest_framework import serializers + + +class MovieSerializer(serializers.Serializer): + actors = serializers.CharField(source="Actors") + director = serializers.CharField(source="Director") + genre = serializers.CharField(source="Genre") + imdb_id = serializers.CharField(source="imdbID") + critic_scores = serializers.CharField(source="Ratings") + mpaa_rating = serializers.CharField(source="Rated") + media_type = serializers.CharField(source="Type") + plot = serializers.CharField(source="Plot") + poster = serializers.CharField(source="Poster") + runtime = serializers.CharField(source="Runtime") + title = serializers.CharField(source="Title") + year = serializers.CharField(source="Year") + + +class MovieResultSerializer(serializers.Serializer): + title = serializers.CharField(source="Title") + year = serializers.CharField(source="Year") + imdb_id = serializers.CharField(source="imdbID") + media_type = serializers.CharField(source="Type") + poster = serializers.CharField(source="Poster") diff --git a/movie_db/tests.py b/movie_db/tests.py new file mode 100644 index 0000000..6cb44f7 --- /dev/null +++ b/movie_db/tests.py @@ -0,0 +1,6 @@ +from unittest import TestCase + + +class OmdbTestCase(TestCase): + def test_movie_db(self): + self.assertTrue(True) diff --git a/movie_db/views.py b/movie_db/views.py new file mode 100644 index 0000000..536c6e5 --- /dev/null +++ b/movie_db/views.py @@ -0,0 +1,25 @@ +from django.http import JsonResponse +from movie_db.db_providers.omdb import OMDb + + +def omdb_search(request): + query = request.GET.get("q") + if not query: + return JsonResponse({"Error": "Missing query"}, status=400) + + search_type = request.GET.get("type") + omdb = OMDb() + + results = omdb.search(query, {"type": search_type}) + if "error" in results: + return parse_error(results) + + return JsonResponse(results, safe=False) + + +def parse_error(results): + error_json = results["error"] + if "Error" in error_json and error_json["Error"] == "Movie not found!": + return JsonResponse({}, status=404) + else: + return JsonResponse("Error while searching for movie.", status=500) \ No newline at end of file diff --git a/movie_manager/admin.py b/movie_manager/admin.py index 8c38f3f..7773cfb 100644 --- a/movie_manager/admin.py +++ b/movie_manager/admin.py @@ -1,3 +1,38 @@ +from zoneinfo import ZoneInfo + from django.contrib import admin +from django.utils import timezone + +from movie_manager.models import Movie, MovieList, Schedule, Showing + # Register your models here. +@admin.register(Movie) +class MovieAdmin(admin.ModelAdmin): + list_display = ["title", "imdb_id", "added_by"] + + +@admin.register(MovieList) +class MovieListAdmin(admin.ModelAdmin): + list_display = ["name", "owner"] + + +@admin.register(Schedule) +class ScheduleAdmin(admin.ModelAdmin): + list_display = ["name", "owner"] + + +@admin.register(Showing) +class ShowingAdmin(admin.ModelAdmin): + list_display = ["local_showtime", "showtime", "movie"] + + def local_showtime(self, obj): + if obj.showtime: + target_tz = ZoneInfo("America/Chicago") + with timezone.override(target_tz): + local_time = timezone.localtime(obj.showtime) + return local_time.strftime("%Y-%m-%d %H:%M") + return "Invalid datetime" + + local_showtime.short_description = "Showtime (Local)" + local_showtime.admin_order_field = "showtime" diff --git a/movie_manager/migrations/0001_initial.py b/movie_manager/migrations/0001_initial.py index 8b7d822..19195fa 100644 --- a/movie_manager/migrations/0001_initial.py +++ b/movie_manager/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.4 on 2025-03-31 04:04 +# Generated by Django 5.1.4 on 2025-04-21 00:50 import django.db.models.deletion from django.conf import settings @@ -20,18 +20,22 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=100)), ('imdb_id', models.CharField(max_length=100)), - ('year', models.IntegerField()), - ('critic_score', models.CharField(max_length=500)), - ('genre', models.CharField(max_length=100)), - ('director', models.CharField(max_length=500)), - ('actors', models.CharField(max_length=500)), - ('plot', models.CharField(max_length=500)), - ('poster', models.CharField(max_length=500)), - ('last_watched', models.DateTimeField()), + ('year', models.IntegerField(blank=True, null=True)), + ('director', models.CharField(blank=True, max_length=500, null=True)), + ('actors', models.TextField(blank=True, null=True)), + ('plot', models.TextField(blank=True, null=True)), + ('genre', models.CharField(blank=True, max_length=100, null=True)), + ('mpaa_rating', models.CharField(blank=True, max_length=20, null=True)), + ('critic_scores', models.TextField(blank=True, null=True)), + ('poster', models.TextField(blank=True, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('added_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), ], + options={ + 'ordering': ['title'], + }, ), migrations.CreateModel( name='MovieList', @@ -42,7 +46,40 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('movies', models.ManyToManyField(to='movie_manager.movie')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Schedule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('public', models.BooleanField(default=False)), + ('slug', models.SlugField(default='', max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), ], ), + migrations.CreateModel( + name='Showing', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('public', models.BooleanField(default=False)), + ('showtime', models.DateTimeField()), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='movie_manager.movie')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['showtime'], + }, + ), ] diff --git a/movie_manager/migrations/0002_alter_movie_options_alter_movielist_options_and_more.py b/movie_manager/migrations/0002_alter_movie_options_alter_movielist_options_and_more.py deleted file mode 100644 index b0f1663..0000000 --- a/movie_manager/migrations/0002_alter_movie_options_alter_movielist_options_and_more.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.1.4 on 2025-04-07 05:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('movie_manager', '0001_initial'), - ] - - operations = [ - migrations.AlterModelOptions( - name='movie', - options={'ordering': ['title']}, - ), - migrations.AlterModelOptions( - name='movielist', - options={'ordering': ['name']}, - ), - migrations.AddField( - model_name='movielist', - name='movies', - field=models.ManyToManyField(to='movie_manager.movie'), - ), - ] diff --git a/movie_manager/migrations/0002_showing_schedule.py b/movie_manager/migrations/0002_showing_schedule.py new file mode 100644 index 0000000..b081684 --- /dev/null +++ b/movie_manager/migrations/0002_showing_schedule.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.4 on 2025-04-21 01:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('movie_manager', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='showing', + name='schedule', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='movie_manager.schedule'), + preserve_default=False, + ), + ] diff --git a/movie_manager/migrations/0003_alter_schedule_options.py b/movie_manager/migrations/0003_alter_schedule_options.py new file mode 100644 index 0000000..08ee78e --- /dev/null +++ b/movie_manager/migrations/0003_alter_schedule_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.4 on 2025-04-21 03:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('movie_manager', '0002_showing_schedule'), + ] + + operations = [ + migrations.AlterModelOptions( + name='schedule', + options={'ordering': ['name']}, + ), + ] diff --git a/movie_manager/migrations/0003_movie_added_by.py b/movie_manager/migrations/0003_movie_added_by.py deleted file mode 100644 index 94fee8e..0000000 --- a/movie_manager/migrations/0003_movie_added_by.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.1.4 on 2025-04-08 00:18 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('movie_manager', '0002_alter_movie_options_alter_movielist_options_and_more'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AddField( - model_name='movie', - name='added_by', - field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), - preserve_default=False, - ), - ] diff --git a/movie_manager/migrations/0004_alter_movie_last_watched_showing_schedule.py b/movie_manager/migrations/0004_alter_movie_last_watched_showing_schedule.py deleted file mode 100644 index 9895ec9..0000000 --- a/movie_manager/migrations/0004_alter_movie_last_watched_showing_schedule.py +++ /dev/null @@ -1,50 +0,0 @@ -# Generated by Django 5.1.4 on 2025-04-08 03:39 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('movie_manager', '0003_movie_added_by'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.AlterField( - model_name='movie', - name='last_watched', - field=models.DateTimeField(blank=True, null=True), - ), - migrations.CreateModel( - name='Showing', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('public', models.BooleanField(default=False)), - ('showtime', models.DateTimeField()), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('deleted_at', models.DateTimeField(blank=True, null=True)), - ('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='movie_manager.movie')), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'ordering': ['showtime'], - }, - ), - migrations.CreateModel( - name='Schedule', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('public', models.BooleanField(default=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('deleted_at', models.DateTimeField(blank=True, null=True)), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('showings', models.ManyToManyField(to='movie_manager.showing')), - ], - ), - ] diff --git a/movie_manager/migrations/0005_showing_slug.py b/movie_manager/migrations/0005_showing_slug.py deleted file mode 100644 index 4d5c0b1..0000000 --- a/movie_manager/migrations/0005_showing_slug.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.4 on 2025-04-08 03:45 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('movie_manager', '0004_alter_movie_last_watched_showing_schedule'), - ] - - operations = [ - migrations.AddField( - model_name='showing', - name='slug', - field=models.SlugField(default='', max_length=100), - ), - ] diff --git a/movie_manager/migrations/0006_remove_showing_slug_schedule_slug.py b/movie_manager/migrations/0006_remove_showing_slug_schedule_slug.py deleted file mode 100644 index dfe8b61..0000000 --- a/movie_manager/migrations/0006_remove_showing_slug_schedule_slug.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 5.1.4 on 2025-04-08 04:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('movie_manager', '0005_showing_slug'), - ] - - operations = [ - migrations.RemoveField( - model_name='showing', - name='slug', - ), - migrations.AddField( - model_name='schedule', - name='slug', - field=models.SlugField(default='', max_length=100), - ), - ] diff --git a/movie_manager/models.py b/movie_manager/models.py index 171e9e2..ccbb950 100644 --- a/movie_manager/models.py +++ b/movie_manager/models.py @@ -1,18 +1,20 @@ -from django.db import models from django.contrib.auth.models import User +from django.db import models +from django.db.models import SET_NULL + class Movie(models.Model): title = models.CharField(max_length=100) - imdb_id = models.CharField(max_length=100) - year = models.IntegerField() - critic_score = models.CharField(max_length=500) - genre = models.CharField(max_length=100) - director = models.CharField(max_length=500) - actors = models.CharField(max_length=500) - plot = models.CharField(max_length=500) - poster = models.CharField(max_length=500) - last_watched = models.DateTimeField(null=True, blank=True) - added_by = models.ForeignKey(User, on_delete=models.CASCADE) + imdb_id = models.CharField(max_length=100, db_index=True, unique=True) + year = models.IntegerField(null=True, blank=True) + director = models.CharField(max_length=500, null=True, blank=True) + actors = models.TextField(null=True, blank=True) + plot = models.TextField(null=True, blank=True) + genre = models.CharField(max_length=100, null=True, blank=True) + mpaa_rating = models.CharField(max_length=20, null=True, blank=True) + critic_scores = models.TextField(null=True, blank=True) + poster = models.TextField(null=True, blank=True) + added_by = models.ForeignKey(User, on_delete=SET_NULL, null=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) deleted_at = models.DateTimeField(null=True, blank=True) @@ -25,33 +27,44 @@ class Movie(models.Model): class MovieList(models.Model): - name = models.CharField(max_length=100) - public = models.BooleanField(default=False) + name = models.CharField(max_length=100, db_index=True) + public = models.BooleanField(default=False, db_index=True) owner = models.ForeignKey(User, on_delete=models.CASCADE) movies = models.ManyToManyField(Movie) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) deleted_at = models.DateTimeField(null=True, blank=True) + class Meta: + ordering = ["name"] + indexes = [ + models.Index(fields=["public", "owner"]), + ] + + def __str__(self): + return self.name + + +class Schedule(models.Model): + name = models.CharField(max_length=100) + owner = models.ForeignKey(User, on_delete=models.CASCADE) + public = models.BooleanField(default=False) + slug = models.SlugField(max_length=100, default="", db_index=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + deleted_at = models.DateTimeField(null=True, blank=True) + class Meta: ordering = ["name"] def __str__(self): return self.name -class Schedule(models.Model): - name = models.CharField(max_length=100) - owner = models.ForeignKey(User, on_delete=models.CASCADE) - public = models.BooleanField(default=False) - showings = models.ManyToManyField("Showing") - slug = models.SlugField(max_length=100, default="") - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) class Showing(models.Model): movie = models.ForeignKey(Movie, on_delete=models.CASCADE) owner = models.ForeignKey(User, on_delete=models.CASCADE) + schedule = models.ForeignKey(Schedule, on_delete=models.CASCADE) public = models.BooleanField(default=False) showtime = models.DateTimeField() created_at = models.DateTimeField(auto_now_add=True) @@ -60,3 +73,7 @@ class Showing(models.Model): class Meta: ordering = ["showtime"] + + def __str__(self): + showtime = self.showtime.strftime("%Y-%m-%d %H:%M") + return showtime diff --git a/movie_manager/permissions.py b/movie_manager/permissions.py new file mode 100644 index 0000000..3dae833 --- /dev/null +++ b/movie_manager/permissions.py @@ -0,0 +1,6 @@ +from rest_framework import permissions +from rest_framework.permissions import SAFE_METHODS + +class ReadOnly(permissions.BasePermission): + def has_permission(self, request, view): + return request.method in SAFE_METHODS diff --git a/movie_manager/serializers.py b/movie_manager/serializers.py index bebebb0..c9bb829 100644 --- a/movie_manager/serializers.py +++ b/movie_manager/serializers.py @@ -1,25 +1,57 @@ from gunicorn.config import User from rest_framework import serializers + from movie_manager.models import Movie, MovieList, Schedule, Showing class MovieSerializer(serializers.ModelSerializer): + has_been_scheduled = serializers.SerializerMethodField() + class Meta: model = Movie - fields = '__all__' + fields = [ + "id", + "title", + "imdb_id", + "year", + "director", + "actors", + "plot", + "genre", + "mpaa_rating", + "critic_scores", + "poster", + "added_by_id", + "has_been_scheduled", + ] + + def get_has_been_scheduled(self, obj): + return Showing.objects.filter(movie_id=obj.id).exists() -class MovieListSerializer(serializers.ModelSerializer): +class MovieListListSerializer(serializers.ModelSerializer): movie_count = serializers.SerializerMethodField() - movies = MovieSerializer(read_only=True, many=True) class Meta: model = MovieList - fields = ["id","name","owner","public", "movies", "movie_count"] - + fields = ["id", "name", "owner", "public", "movie_count"] def get_movie_count(self, obj): - return len(obj.movies.all()) + return obj.movies.count() + + +class MovieListSerializer(serializers.ModelSerializer): + movies = MovieSerializer(read_only=True, many=True) + serializer_class = MovieSerializer + owner = serializers.PrimaryKeyRelatedField(read_only=True) + + def get_queryset(self): + return MovieList.objects.prefetch_related("movies", "movies__showing_set") + + class Meta: + model = MovieList + fields = ["id", "name", "owner", "public", "movies"] + class UserSerializer(serializers.Serializer): class Meta: @@ -32,14 +64,22 @@ class ShowingSerializer(serializers.ModelSerializer): class Meta: model = Showing - fields = ["public", "showtime", "movie", "owner"] + fields = ["id", "public", "showtime", "movie", "owner"] + + # def to_internal_value(self, data): + # validated_data = super().to_internal_value(data) + + # if "showtime" in validated_data and timezone.is_naive( + # validated_data["showtime"] + # ): + # validated_data["showtime"] = timezone.make_aware(validated_data["showtime"]) + + # return validated_data class ScheduleSerializer(serializers.ModelSerializer): - name = serializers.CharField(read_only=True) - showings = ShowingSerializer(read_only=True, many=True) + showings = ShowingSerializer(source="showing_set", read_only=True, many=True) class Meta: model = Schedule - fields = ["name", "owner","public","slug", "showings"] - + fields = ["name", "owner", "public", "slug", "showings"] diff --git a/movie_manager/tests.py b/movie_manager/tests.py index 7ce503c..09fcbb8 100644 --- a/movie_manager/tests.py +++ b/movie_manager/tests.py @@ -1,3 +1,90 @@ -from django.test import TestCase +import json -# Create your tests here. +from django.contrib.auth.models import User +from django.utils import timezone +from freezegun import freeze_time +from rest_framework import status +from rest_framework.test import APITestCase, APIClient + +from movie_manager.models import Movie, Schedule, Showing + + +class ShowingViewsetTestCase(APITestCase): + def setUp(self): + self.client: APIClient = APIClient() + self.movie: Movie = Movie.objects.create(title="Test Movie") + self.owner: User = User.objects.create(id=1, username="test_user") + self.schedule: Schedule = Schedule.objects.create( + owner=self.owner, name="Test Schedule" + ) + + def test_it_creates_a_new_showing(self): + self.client.force_authenticate(user=self.owner) + + showing_time = timezone.now().isoformat().replace("+00:00", "Z") + response = self.client.post( + "/v1/showings/", + { + "movie": self.movie.id, + "public": True, + "schedule": self.schedule.id, + "showtime": showing_time, + }, + ) + + response_data = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response_data.get("showtime"), showing_time) + self.assertEqual(response_data.get("movie").get("title"), "Test Movie") + + @freeze_time("2025-07-03 04:59:00") # 2025-07-02 23:59 CDT in UTC + def test_it_returns_active_showings(self): + self.client.force_authenticate(user=self.schedule.owner) + + showtimes_america_chicago_utc = [ + "2025-07-08T04:59:59.000Z", # 2025-07-07 11:59pm + "2025-07-03T04:59:59.000Z", # 2025-07-02 11:59pm + "2025-07-03T04:00:00.000Z", # 2025-07-02 11:00pm + "2025-07-01T04:59:00.000Z", # 2025-06-30 11:59pm + ] + + for showtime in showtimes_america_chicago_utc: + Showing.objects.create( + movie=self.movie, + schedule=self.schedule, + showtime=showtime, + public=True, + owner=self.schedule.owner, + ) + + response = self.client.get(f"/v1/schedules/{self.schedule.id}/") + parsed_schedule = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(parsed_schedule.get("showings")), 2) + + +class ScheduleViewsetTestCase(APITestCase): + def setUp(self): + self.client: APIClient = APIClient() + self.test_user: User = User.objects.create(id=1, username="test_user") + + def test_it_creates_a_new_schedule(self): + self.client.force_authenticate(user=self.test_user) + response = self.client.post( + "/v1/schedules/", + { + "name": "Test Schedule", + "owner": self.test_user.id, + "public": True, + "slug": "test-schedule", + }, + ) + + response_data = json.loads(response.content) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response_data.get("name"), "Test Schedule") + self.assertEqual(response_data.get("owner"), 1) + self.assertEqual(response_data.get("public"), True) + self.assertEqual(response_data.get("slug"), "test-schedule") diff --git a/movie_manager/views.py b/movie_manager/views.py index e08d86e..e69de29 100644 --- a/movie_manager/views.py +++ b/movie_manager/views.py @@ -1,138 +0,0 @@ -import datetime - -from django.http import JsonResponse -from django.contrib.auth.models import User -from rest_framework import permissions, viewsets -from knox.auth import TokenAuthentication -from rest_framework.decorators import action -from rest_framework.exceptions import NotFound - -from movie_manager.models import Movie, MovieList, Schedule, Showing -from movie_manager.serializers import MovieListSerializer, MovieSerializer, ScheduleSerializer, ShowingSerializer - - -# Create your views here. -class MovieViewset(viewsets.ModelViewSet): - queryset = Movie.objects.all().order_by("title") - authentication_classes = [TokenAuthentication] - permission_classes = [permissions.IsAuthenticated] - - serializer_class = MovieSerializer - -class MovieListViewset(viewsets.ModelViewSet): - queryset = MovieList.objects.all().order_by("name") - authentication_classes = [TokenAuthentication] - permission_classes = [permissions.IsAuthenticated] - - serializer_class = MovieListSerializer - - - def retrieve(self, request, pk=None, *args, **kwargs): - movie_list = MovieList.objects.get(pk=pk) - return JsonResponse(MovieListSerializer(movie_list).data) - - - def update(self, request, pk=None, *args, **kwargs): - movie_list = MovieList.objects.get(pk=pk) - movie_list.name = request.data.get('name') - movie_list.owner = User.objects.get(pk=request.data.get("owner")) - - if request.data.get('movies'): - movie_ids = request.data.get('movies') - for movie_id in movie_ids: - try: - movie = Movie.objects.get(pk=movie_id) - movie_list.movies.add(movie) - except Movie.DoesNotExist: - raise NotFound(f"Movie {movie_id} does not exist") - - removed_movies = Movie.objects.exclude(id__in=movie_ids) - for removed_movie in removed_movies: - removed_movie.delete() - - movie_list.save() - - return JsonResponse(MovieListSerializer(movie_list).data) - - @action(detail=True, methods=['put', 'delete'], url_path='movie/(?P[0-9]+)') - def add_movie(self, request, pk=None, movie_id=None, *args, **kwargs): - if request.method == 'DELETE': - return self.remove_movie(request, pk, movie_id) - - movie_list = MovieList.objects.get(pk=pk) - movie = Movie.objects.get(pk=movie_id) - movie_list.movies.add(movie) - - return JsonResponse(MovieListSerializer(movie_list).data) - - def remove_movie(self, request, pk=None, movie_id=None, *args, **kwargs): - movie = Movie.objects.get(pk=movie_id) - - movie_list = MovieList.objects.get(pk=pk) - movie_list.movies.remove(movie) - - return JsonResponse(MovieListSerializer(movie_list).data) - -class ScheduleViewset(viewsets.ModelViewSet): - queryset = Schedule.objects.all().order_by("name") - authentication_classes = [TokenAuthentication] - permission_classes = [permissions.IsAuthenticated] - - serializer_class = ScheduleSerializer - - def retrieve(self, request, pk=None, *args, **kwargs): - # Get the schedule instance - instance = self.get_object() - today = datetime.datetime.now() - - upcoming_showings = instance.showings.filter(showtime__gte=today) - - # Create a serialized response - serializer = self.get_serializer(instance) - data = serializer.data - - # Replace all showings with only future showings - data['showings'] = ShowingSerializer(upcoming_showings, many=True).data - - - if request.GET.get('past_showings') == 'true': - past_showings = instance.showings.filter(showtime__lt=today) - - # Add both to the response - data['past_showings'] = [ - {'id': showing.id, 'showtime': showing.showtime.isoformat(), "movie": MovieSerializer(showing.movie).data} - for showing in past_showings - ] - else: - data['past_showings'] = [] - - return JsonResponse(data) - - -class ShowingViewset(viewsets.ModelViewSet): - queryset = Showing.objects.all().order_by("showtime") - authentication_classes = [TokenAuthentication] - permission_classes = [permissions.IsAuthenticated] - - serializer_class = ShowingSerializer - - def create(self, request, *args, **kwargs): - movie_id = request.data.get('movie') - movie = Movie.objects.get(pk=movie_id) - - schedule_id = request.data.get('schedule') - schedule = Schedule.objects.get(pk=schedule_id) - - showing = Showing.objects.create( - movie=movie, - schedule=schedule, - showtime=request.data.get("showtime"), - public=request.data.get("public"), - owner=User.objects.get(pk=request.data.get("owner")) - ) - - schedule.showings.add(showing) - - return JsonResponse(ShowingSerializer(showing).data) - - diff --git a/movie_manager/viewsets/__init__.py b/movie_manager/viewsets/__init__.py new file mode 100644 index 0000000..da282d0 --- /dev/null +++ b/movie_manager/viewsets/__init__.py @@ -0,0 +1,11 @@ +from .movie import MovieViewset +from .movie_list import MovieListViewset +from .schedule import ScheduleViewset +from .showing import ShowingViewset + +__all__ = [ + "MovieViewset", + "MovieListViewset", + "ScheduleViewset", + "ShowingViewset", +] diff --git a/movie_manager/viewsets/movie.py b/movie_manager/viewsets/movie.py new file mode 100644 index 0000000..c7b6977 --- /dev/null +++ b/movie_manager/viewsets/movie.py @@ -0,0 +1,37 @@ +from django.http import JsonResponse +from rest_framework import permissions, viewsets + +from movie_db.db_providers.omdb import OMDb +from movie_manager.models import Movie + +from knox.auth import TokenAuthentication + +from movie_manager.serializers import MovieSerializer + + +class MovieViewset(viewsets.ModelViewSet): + queryset = Movie.objects.all().order_by("title") + authentication_classes = [TokenAuthentication] + permission_classes = [permissions.IsAuthenticated] + + serializer_class = MovieSerializer + + def update(self, request, pk=None, *args, **kwargs): + omdb = OMDb() + updated_movie = omdb.search(request.data.get("imdb_id"), {"type": "imdb_id"}) + + movie = Movie.objects.get(pk=pk) + + movie.title = updated_movie["title"] + movie.actors = updated_movie["actors"] + movie.year = updated_movie["year"] + movie.critic_scores = updated_movie["critic_scores"] + movie.mpaa_rating = updated_movie["mpaa_rating"] + movie.director = updated_movie["director"] + movie.poster = updated_movie["poster"] + movie.plot = updated_movie["plot"] + movie.genre = updated_movie["genre"] + + movie.save() + + return JsonResponse(MovieSerializer(movie).data) diff --git a/movie_manager/viewsets/movie_list.py b/movie_manager/viewsets/movie_list.py new file mode 100644 index 0000000..75730b6 --- /dev/null +++ b/movie_manager/viewsets/movie_list.py @@ -0,0 +1,120 @@ +from django.http import JsonResponse +from django.db import models +from django.contrib.auth.models import User +from rest_framework import permissions, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import NotFound + +from movie_db.db_providers.omdb import OMDb +from movie_manager.models import MovieList, Movie + +from knox.auth import TokenAuthentication + +from movie_manager.permissions import ReadOnly +from movie_manager.serializers import MovieListSerializer, MovieListListSerializer + + +class MovieListViewset(viewsets.ModelViewSet): + queryset = MovieList.objects.all() + authentication_classes = [TokenAuthentication] + permission_classes = [permissions.IsAuthenticated | ReadOnly] + + def get_serializer_class(self): + if self.action == "list": + return MovieListListSerializer + else: + return MovieListSerializer + + def get_queryset(self): + base_qs = MovieList.objects.all() + + if self.action == "list": + if self.request.user.is_authenticated: + return base_qs.filter( + models.Q(public=True) | models.Q(owner=self.request.user) + ).order_by("name") + + return base_qs.filter(public=True).order_by("name") + else: + return MovieList.objects.prefetch_related( + "movies", "movies__showing_set" + ).order_by("name") + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def get_permissions(self): + if self.action in ["update", "partial_update", "destroy"]: + self.permission_classes = [permissions.IsAuthenticated] + return super().get_permissions() + + def create(self, request, *args, **kwargs): + movie_list = MovieList.objects.create( + name=request.data.get("name"), + owner=request.user, + ) + + return JsonResponse(MovieListSerializer(movie_list).data) + + def update(self, request, pk=None, *args, **kwargs): + movie_list = MovieList.objects.get(pk=pk) + movie_list.name = request.data.get("name") + movie_list.owner = User.objects.get(pk=request.data.get("owner")) + + if request.data.get("movies"): + movie_ids = request.data.get("movies") + for movie_id in movie_ids: + try: + movie = Movie.objects.get(pk=movie_id) + movie_list.movies.add(movie) + except Movie.DoesNotExist: + raise NotFound(f"Movie {movie_id} does not exist") + + removed_movies = Movie.objects.exclude(id__in=movie_ids) + for removed_movie in removed_movies: + removed_movie.delete() + + movie_list.save() + + return JsonResponse(MovieListSerializer(movie_list).data) + + @action( + detail=True, methods=["put", "delete"], url_path="movie/(?Ptt[0-9]+)" + ) + def add_movie(self, request, pk=None, imdb_id=None, *args, **kwargs): + if request.method == "DELETE": + return self.remove_movie(request, pk, imdb_id) + + movie_list = MovieList.objects.get(pk=pk) + try: + new_movie = Movie.objects.get(imdb_id=imdb_id) + except Movie.DoesNotExist: + omdb = OMDb() + movie = omdb.search(imdb_id, {"type": "imdb_id"}) + + new_movie = Movie.objects.create( + title=movie["title"], + actors=movie["actors"], + year=movie["year"], + imdb_id=movie["imdb_id"], + poster=movie["poster"], + plot=movie["plot"], + genre=movie["genre"], + critic_scores=movie["critic_scores"], + mpaa_rating=movie["mpaa_rating"], + director=movie["director"], + added_by_id=request.user.id, + ) + + movie_list.movies.add(new_movie) + + return JsonResponse(MovieListSerializer(movie_list).data) + + @staticmethod + def remove_movie(request, pk=None, imdb_id=None, *args, **kwargs): + movie = Movie.objects.filter(imdb_id=imdb_id).first() + + movie_list = MovieList.objects.get(pk=pk) + movie_list.movies.remove(movie) + + return JsonResponse(MovieListSerializer(movie_list).data) diff --git a/movie_manager/viewsets/schedule.py b/movie_manager/viewsets/schedule.py new file mode 100644 index 0000000..41d7364 --- /dev/null +++ b/movie_manager/viewsets/schedule.py @@ -0,0 +1,50 @@ +from django.http import JsonResponse +from django.utils import timezone +from knox.auth import TokenAuthentication +from rest_framework import viewsets, permissions + +from movie_manager.models import Schedule, Showing +from movie_manager.permissions import ReadOnly +from movie_manager.serializers import ( + ScheduleSerializer, + ShowingSerializer, + MovieSerializer, +) + + +class ScheduleViewset(viewsets.ModelViewSet): + queryset = Schedule.objects.all().order_by("name") + authentication_classes = [TokenAuthentication] + permission_classes = [permissions.IsAuthenticated | ReadOnly] + + serializer_class = ScheduleSerializer + + def retrieve(self, request, pk=None, *args, **kwargs): + # Get the schedule instance + instance = self.get_object() + now = timezone.now() + + upcoming_showings = Showing.objects.filter(showtime__gte=now, schedule=instance) + + serializer = self.get_serializer(instance) + data = serializer.data + + # Replace all showings with only future showings + data["showings"] = ShowingSerializer(upcoming_showings, many=True).data + + if request.GET.get("past_showings") == "true": + past_showings = Showing.objects.filter(showtime__lt=now, schedule=instance) + + # Add both to the response + data["past_showings"] = [ + { + "id": past_showing.id, + "showtime": past_showing.showtime.isoformat(), + "movie": MovieSerializer(past_showing.movie).data, + } + for past_showing in past_showings + ] + else: + data["past_showings"] = [] + + return JsonResponse(data) diff --git a/movie_manager/viewsets/showing.py b/movie_manager/viewsets/showing.py new file mode 100644 index 0000000..a52f7c1 --- /dev/null +++ b/movie_manager/viewsets/showing.py @@ -0,0 +1,36 @@ +from django.http import JsonResponse +from django.utils.dateparse import parse_datetime +from knox.auth import TokenAuthentication +from rest_framework import viewsets, permissions + +from movie_manager.models import Showing, Movie, Schedule +from movie_manager.permissions import ReadOnly +from movie_manager.serializers import ShowingSerializer + + +class ShowingViewset(viewsets.ModelViewSet): + queryset = Showing.objects.all().order_by("showtime") + authentication_classes = [TokenAuthentication] + permission_classes = [permissions.IsAuthenticated | ReadOnly] + + serializer_class = ShowingSerializer + + def create(self, request, *args, **kwargs): + movie_id = request.data.get("movie") + movie = Movie.objects.get(pk=movie_id) + + schedule_id = request.data.get("schedule") + schedule = Schedule.objects.get(pk=schedule_id) + + showtime_str = request.data.get("showtime") + showtime = parse_datetime(showtime_str) + + showing = Showing.objects.create( + movie=movie, + schedule=schedule, + showtime=showtime, + public=request.data.get("public"), + owner=request.user, + ) + + return JsonResponse(ShowingSerializer(showing).data, status=201) diff --git a/movienight/settings.py b/movienight/settings.py index 4fcd523..dc59df7 100644 --- a/movienight/settings.py +++ b/movienight/settings.py @@ -10,14 +10,12 @@ For the full list of settings and their values, see https://docs.djangoproject.com/en/5.1/ref/settings/ """ -from pathlib import Path import os - +from pathlib import Path # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent - # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ @@ -28,8 +26,7 @@ SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") DEBUG = bool(os.environ.get("DEBUG", default=0)) ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "127.0.0.1").split(",") -CORS_ALLOWED_ORIGINS = ["http://localhost:3000"] - +CORS_ALLOWED_ORIGINS = os.environ.get("DJANGO_ALLOWED_ORIGINS", "http://localhost:3000").split(",") # Application definition @@ -37,6 +34,7 @@ INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", + "corsheaders", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", @@ -44,7 +42,7 @@ INSTALLED_APPS = [ "rest_framework.authtoken", "knox", "movie_manager", - "corsheaders", + "users", ] MIDDLEWARE = [ @@ -78,7 +76,6 @@ TEMPLATES = [ WSGI_APPLICATION = "movienight.wsgi.application" - # Database # https://docs.djangoproject.com/en/5.1/ref/settings/#databases @@ -95,7 +92,6 @@ DATABASES = { } } - # Password validation # https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators @@ -114,7 +110,6 @@ AUTH_PASSWORD_VALIDATORS = [ }, ] - # Internationalization # https://docs.djangoproject.com/en/5.1/topics/i18n/ @@ -126,10 +121,11 @@ USE_I18N = True USE_TZ = True +OMDB_API_KEY = os.environ.get("OMDB_API_KEY") + # Django Rest Framework REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": [ - #'rest_framework.authentication.SessionAuthentication', "knox.auth.TokenAuthentication", ], } diff --git a/movienight/urls.py b/movienight/urls.py index 6889ee7..e36e1b5 100644 --- a/movienight/urls.py +++ b/movienight/urls.py @@ -2,31 +2,42 @@ URL configuration for movienight project. """ -import knox -from knox import views as knox_views +from django.conf import settings +from django.conf.urls.static import static from django.contrib import admin from django.urls import path, include -from django.conf.urls.static import static -from django.conf import settings +from rest_framework.authtoken.views import obtain_auth_token from rest_framework.routers import DefaultRouter +from movie_db import views as movie_db_views +from movie_manager.viewsets import ( + MovieViewset, + MovieListViewset, + ScheduleViewset, + ShowingViewset, +) from users import views as user_views -from movie_manager import views as movie_views -from rest_framework.authtoken.views import obtain_auth_token +from users.viewsets import UserViewSet, GroupViewSet +from users.viewsets.user import register, UserProfileViewSet router = DefaultRouter() -router.register(r"api/users", user_views.UserViewSet) -router.register(r"api/groups", user_views.GroupViewSet) -router.register(r"api/movies", movie_views.MovieViewset) -router.register(r"api/lists", movie_views.MovieListViewset) -router.register(r"api/schedules", movie_views.ScheduleViewset) -router.register(r"api/showings", movie_views.ShowingViewset) +router.register(r"v1/users", UserViewSet) +router.register(r"v1/groups", GroupViewSet) +router.register(r"v1/movies", MovieViewset) +router.register(r"v1/lists", MovieListViewset) +router.register(r"v1/schedules", ScheduleViewset) +router.register(r"v1/showings", ShowingViewset) +router.register(r"v1/users/profiles/", UserProfileViewSet) urlpatterns = [ - path("", include(router.urls)), - path("admin/", admin.site.urls), - path(r"api/auth/token/", obtain_auth_token), - path(r"api/auth/login/", user_views.LoginView.as_view(), name="knox_login"), - path(r"api/auth/register/", user_views.register, name="register"), - path(r"api/auth/", include("knox.urls")), -] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + path("", include(router.urls)), + path("admin/", admin.site.urls), + path(r"v1/auth/token/", obtain_auth_token), + path(r"v1/auth/login/", user_views.LoginView.as_view(), name="knox_login"), + path(r"v1/auth/register/", register, name="register"), + path(r"v1/movies/search", movie_db_views.omdb_search, name="omdb_search"), + path(r"v1/auth/", include("knox.urls")), + path('v1/users/profile', UserProfileViewSet.as_view({"get": "current_user_profile"}), + name="current_user_profile"), + path('v1/users/profiles//', UserProfileViewSet.as_view({"get": "retrieve"})) + ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/requirements.txt b/requirements.txt index 7c9b3b9..73606df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -django==5.1.4 +django==5.2.2 djangorestframework django-rest-knox markdown @@ -6,3 +6,6 @@ django-filter gunicorn==23.0.0 psycopg2-binary==2.9.10 django-cors-headers==4.7.0 +requests==2.32.3 +freezegun==1.5.2 +coverage==7.9.1 diff --git a/users/migrations/0001_initial.py b/users/migrations/0001_initial.py new file mode 100644 index 0000000..8b80e3f --- /dev/null +++ b/users/migrations/0001_initial.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.2 on 2025-07-08 02:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=100, null=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/users/models.py b/users/models.py index 71a8362..416811d 100644 --- a/users/models.py +++ b/users/models.py @@ -1,3 +1,12 @@ +from django.contrib.auth.models import User from django.db import models + # Create your models here. +class UserProfile(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE) + name = models.CharField(max_length=100, null=True, blank=True) + + @property + def lists(self): + return self.user.movielist_set.all() diff --git a/users/permissions.py b/users/permissions.py new file mode 100644 index 0000000..3dae833 --- /dev/null +++ b/users/permissions.py @@ -0,0 +1,6 @@ +from rest_framework import permissions +from rest_framework.permissions import SAFE_METHODS + +class ReadOnly(permissions.BasePermission): + def has_permission(self, request, view): + return request.method in SAFE_METHODS diff --git a/users/serializers.py b/users/serializers.py index a269baa..33f9a3f 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -1,12 +1,35 @@ from django.contrib.auth import authenticate -from rest_framework import serializers from django.contrib.auth.models import User, Group +from rest_framework import serializers + +from movie_manager.serializers import MovieListSerializer +from users.models import UserProfile class UserSerializer(serializers.HyperlinkedModelSerializer): class Meta: model = User - fields = ["url", "username", "email", "password", "groups"] + fields = ["url", "username", "email", "groups"] + + +class UserProfileSerializer(serializers.HyperlinkedModelSerializer): + name = serializers.SerializerMethodField() + username = serializers.SerializerMethodField() + date_joined = serializers.SerializerMethodField() + lists = MovieListSerializer(many=True, read_only=True) + + class Meta: + model = UserProfile + fields = ["name", "username", "date_joined", "lists"] + + def get_name(self, obj): + return obj.name or "" + + def get_username(self, obj): + return obj.user.username + + def get_date_joined(self, obj): + return obj.user.date_joined class GroupSerializer(serializers.HyperlinkedModelSerializer): diff --git a/users/views.py b/users/views.py index bcc7f13..f231eee 100644 --- a/users/views.py +++ b/users/views.py @@ -1,40 +1,9 @@ from django.contrib.auth import login -from django.contrib.auth.models import Group, User, AnonymousUser -from rest_framework import permissions, viewsets, status -from knox.auth import TokenAuthentication +from rest_framework import permissions from knox.views import LoginView as KnoxLoginView from rest_framework.authtoken.serializers import AuthTokenSerializer -from rest_framework.response import Response -from rest_framework.decorators import api_view -from users.serializers import GroupSerializer, UserSerializer - - -class UserViewSet(viewsets.ModelViewSet): - authentication_classes = [TokenAuthentication] - permission_classes = [permissions.IsAuthenticated] - - queryset = User.objects.all().order_by("-date_joined") - serializer_class = UserSerializer - - -class GroupViewSet(viewsets.ModelViewSet): - authentication_classes = [TokenAuthentication] - permission_classes = [permissions.IsAuthenticated] - - queryset = Group.objects.all().order_by("name") - serializer_class = GroupSerializer - - -@api_view(["POST"]) -def register(request): - user_data = UserSerializer(data=request.data) - - if user_data.is_valid(): - User.objects.create_user(**user_data.validated_data) - return Response(request.data, status=status.HTTP_201_CREATED) - else: - return Response([], status=status.HTTP_400_BAD_REQUEST) +from users.serializers import UserSerializer class LoginView(KnoxLoginView): diff --git a/users/viewsets/__init__.py b/users/viewsets/__init__.py new file mode 100644 index 0000000..a3d0f22 --- /dev/null +++ b/users/viewsets/__init__.py @@ -0,0 +1,4 @@ +from .group import GroupViewSet +from .user import UserViewSet + +__all__ = ["GroupViewSet", "UserViewSet"] diff --git a/users/viewsets/group.py b/users/viewsets/group.py new file mode 100644 index 0000000..7bbc91d --- /dev/null +++ b/users/viewsets/group.py @@ -0,0 +1,13 @@ +from knox.auth import TokenAuthentication +from django.contrib.auth.models import Group +from rest_framework import viewsets, permissions, status + +from users.serializers import GroupSerializer + + +class GroupViewSet(viewsets.ModelViewSet): + authentication_classes = [TokenAuthentication] + permission_classes = [permissions.IsAuthenticated] + + queryset = Group.objects.all().order_by("name") + serializer_class = GroupSerializer diff --git a/users/viewsets/user.py b/users/viewsets/user.py new file mode 100644 index 0000000..881f612 --- /dev/null +++ b/users/viewsets/user.py @@ -0,0 +1,74 @@ +from django.contrib.auth.models import User +from django.http import JsonResponse +from knox.auth import TokenAuthentication +from rest_framework import viewsets, permissions, status +from rest_framework.decorators import api_view, action +from rest_framework.response import Response + +from users.models import UserProfile +from users.permissions import ReadOnly +from users.serializers import UserSerializer, UserProfileSerializer + + +class UserViewSet(viewsets.ModelViewSet): + authentication_classes = [TokenAuthentication] + permission_classes = [permissions.IsAuthenticated] + + queryset = User.objects.all().order_by("-date_joined") + serializer_class = UserSerializer + + +class UserProfileViewSet(viewsets.ModelViewSet): + authentication_classes = [TokenAuthentication] + permission_classes = [permissions.IsAuthenticated | ReadOnly] + + queryset = UserProfile.objects.all() + serializer_class = UserProfileSerializer + lookup_field = "user__username" + + def retrieve(self, request, pk=None, *args, **kwargs): + try: + username = kwargs.get('user__username') + user = User.objects.get(username=username) + except User.DoesNotExist: + return Response([], status=status.HTTP_404_NOT_FOUND) + + try: + user_profile = UserProfile.objects.get(user=user) + except UserProfile.DoesNotExist: + user_profile = UserProfile( + user=user, + ) + + user_profile.save() + + return JsonResponse(UserProfileSerializer(user_profile).data) + + @action(detail=False) + def current_user_profile(self, request, *args, **kwargs): + try: + user = request.user + except User.DoesNotExist: + return Response([], status=status.HTTP_404_NOT_FOUND) + + try: + user_profile = UserProfile.objects.get(user=user) + except UserProfile.DoesNotExist: + user_profile = UserProfile( + user=user, + ) + + user_profile.save() + + return JsonResponse(UserProfileSerializer(user_profile).data) + + +@api_view(["POST"]) +def register(request): + user_data = UserSerializer(data=request.data) + + if user_data.is_valid(): + User.objects.create_user(**user_data.validated_data) + return Response(request.data, status=status.HTTP_201_CREATED) + else: + return Response([], status=status.HTTP_400_BAD_REQUEST)