Compare commits

...

26 Commits

Author SHA1 Message Date
michal adada84e9a Add test step to CI
continuous-integration/drone/push Build is passing
2023-02-17 15:41:59 +01:00
michal 8cb0554696 Add pre-commit config
continuous-integration/drone/push Build is passing
2023-02-17 15:21:26 +01:00
michal 947d9f740c fix after changing null -> blank 2023-02-17 15:21:04 +01:00
michal c84ae1334e Add extra test case 2023-02-17 15:12:18 +01:00
michal 1abc910621 Fix movievote inlining in admin 2023-02-17 15:07:47 +01:00
michal 5895714fd5 Fix null -> blank 2023-02-17 15:07:21 +01:00
michal 87086cdce9 Add tests and fix bugs
continuous-integration/drone/push Build is passing
2023-02-11 22:40:16 +01:00
michal 54102f0d90 Fix failing test 2023-02-11 19:10:47 +01:00
michal 1c1e911518 Add first tests 2023-02-11 19:10:19 +01:00
michal 75dbd6cadc Add IMDB and CSFD id support to movies
continuous-integration/drone/push Build is passing
2023-02-10 15:23:01 +01:00
michal 232245d602 Fix marking movies as unwatched when not logged in.
continuous-integration/drone/push Build is passing
2023-02-10 11:53:07 +01:00
michal 2e251d7444 Remove rest API code
continuous-integration/drone/push Build is passing
2023-01-24 22:51:09 +01:00
michal c862b72ac8 Commit migration
continuous-integration/drone/push Build is passing
2023-01-24 21:38:26 +01:00
michal 6a3eacbc4a Add comment to vote
continuous-integration/drone/push Build is passing
2023-01-24 21:37:57 +01:00
michal 6ae7bed38d Fix bug
continuous-integration/drone/push Build is passing
2023-01-24 21:21:30 +01:00
michal d5b65dd0b0 Fix bug
continuous-integration/drone/push Build is passing
2023-01-24 21:21:01 +01:00
michal b119c54473 Change how watched filter parameter works
continuous-integration/drone/push Build is passing
2023-01-24 21:09:16 +01:00
michal d4970c6aa4 Fixup: flip the not voted logic
continuous-integration/drone/push Build is passing
2023-01-24 20:47:49 +01:00
michal a573b80f09 Add mark to unvoted movies
continuous-integration/drone/push Build is passing
2023-01-24 20:45:58 +01:00
michal 5221094333 Watchlist sort by score by default
continuous-integration/drone/push Build is passing
2023-01-24 18:55:23 +01:00
michal 4851d50d7e Add basic support for sorting & filtering on the backend 2023-01-17 23:24:36 +01:00
michal edd4e0ca41 Return to watchlist after voting on a movie
continuous-integration/drone/push Build is passing
2023-01-17 23:05:45 +01:00
michal be466c451d Open IMDB/CSFD in new tabs
continuous-integration/drone/push Build is passing
2023-01-17 23:00:58 +01:00
michal 061944850b Match style with other buttons
continuous-integration/drone/push Build is passing
2023-01-17 22:57:55 +01:00
michal 9f7bdf952d Add IMDB & CSFD search to movie details
continuous-integration/drone/push Build is passing
2023-01-17 22:56:48 +01:00
michal 94e34df89b Fix settings.py defaults 2023-01-17 22:56:48 +01:00
24 changed files with 531 additions and 72 deletions
+11
View File
@@ -3,6 +3,17 @@ type: docker
name: default name: default
steps: steps:
- name: test
image: python:3.10
commands:
- pip3 install poetry
- poetry install --no-root
- export SECRET_KEY=$(openssl rand -hex 32)
- export DATABASE_URL=sqlite://$(mktemp)/db.sqlite3
- poetry run ./manage.py test
environment:
ALLOWED_HOSTS: localhost,127.0.0.1
DEBUG: True
- name: deploy - name: deploy
image: caprover/cli-caprover:2.2.3 image: caprover/cli-caprover:2.2.3
commands: commands:
+40
View File
@@ -0,0 +1,40 @@
repos:
- repo: local
hooks:
- id: poetry
name: poetry
language: system
entry: poetry check
files: '^(pyproject.toml|poetry.lock)$'
pass_filenames: false
# - id: black
# name: black
# language: system
# entry: poetry run black --target-version py39
# types_or: [python, pyi]
# require_serial: true
# - id: isort
# name: isort
# language: system
# entry: poetry run isort --profile black --python-version 39
# types_or: [cython, pyi, python]
# require_serial: true
# - id: flake8
# name: flake8
# language: system
# entry: poetry run flake8
# types_or: [python]
# files: "^.*\\.py$"
# - id: mypy
# name: mypy
# language: system
# entry: poetry run mypy --strict --python-version 3.9
# types_or: [python, pyi]
# files: "^.*\\.pyi?$"
- id: test
name: test
language: system
entry: poetry run ./manage.py test
types_or: [python]
files: "^.*\\.py$"
pass_filenames: false
+4 -4
View File
@@ -20,10 +20,10 @@ environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
SECRET_KEY = env("SECRET_KEY") SECRET_KEY = env("SECRET_KEY")
SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", False) SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", False)
CSRF_COOKIE_SAMESITE = env("CSRF_COOKIE_SAMESITE", default="Lax") CSRF_COOKIE_SAMESITE = env("CSRF_COOKIE_SAMESITE", default="Lax")
CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", False) CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=False)
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", []) CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
SECURE_PROXY_SSL_HEADER = env.tuple("SECURE_PROXY_SSL_HEADER", None) SECURE_PROXY_SSL_HEADER = env.tuple("SECURE_PROXY_SSL_HEADER", default=None)
USE_X_FORWARDED_HOST = env.bool("USE_X_FORWARDED_HOST", False) USE_X_FORWARDED_HOST = env.bool("USE_X_FORWARDED_HOST", default=False)
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env('DEBUG') DEBUG = env('DEBUG')
+1 -3
View File
@@ -16,7 +16,6 @@ Including another URLconf
from django.contrib import admin from django.contrib import admin
from django.urls import include, path, reverse from django.urls import include, path, reverse
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from rest_framework import routers, schemas
import auth.urls import auth.urls
import watchlist.urls import watchlist.urls
@@ -26,11 +25,10 @@ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('auth/', include(auth.urls)), path('auth/', include(auth.urls)),
path('watchlist/', include(watchlist.urls)), path('watchlist/', include(watchlist.urls)),
# path('api/watchlist/', include(watchlist.urls.api_router.urls)),
# path('openapi', schemas.get_schema_view( # path('openapi', schemas.get_schema_view(
# title="Movieclub", # title="Movieclub",
# description="", # description="",
# version="0.1.0" # version="0.1.0"
# )), # )),
# path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
] ]
Generated
+13 -1
View File
@@ -181,6 +181,18 @@ files = [
{file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"},
] ]
[[package]]
name = "tblib"
version = "1.7.0"
description = "Traceback serialization library."
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
files = [
{file = "tblib-1.7.0-py2.py3-none-any.whl", hash = "sha256:289fa7359e580950e7d9743eab36b0691f0310fce64dee7d9c31065b8f723e23"},
{file = "tblib-1.7.0.tar.gz", hash = "sha256:059bd77306ea7b419d4f76016aef6d7027cc8a0785579b5aad198803435f882c"},
]
[[package]] [[package]]
name = "tzdata" name = "tzdata"
version = "2022.7" version = "2022.7"
@@ -223,4 +235,4 @@ brotli = ["Brotli"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "c0a2d81dcf191d69565b3de08983d96c7f5ad00a6de9eba5b350b1431a5e99b3" content-hash = "ca22a0efd5d4acfb0a4a09e8567d0a8c90debca1203b7a80262919d8055366da"
+3
View File
@@ -16,6 +16,9 @@ gunicorn = "^20.1.0"
whitenoise = "^6.3.0" whitenoise = "^6.3.0"
[tool.poetry.group.dev.dependencies]
tblib = "^1.7.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
+2
View File
@@ -1,4 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="container-lg">
<h1>Nothing here...</h1> <h1>Nothing here...</h1>
</div>
{% endblock %} {% endblock %}
+29
View File
@@ -1,6 +1,7 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block content %} {% block content %}
<div class="container-lg"> <div class="container-lg">
{% if error %}<div class="alert alert-warning">{{error}}</div>{% endif %}
<form action="{% url 'watchlist:delete' movie.id %}" method="post"> <form action="{% url 'watchlist:delete' movie.id %}" method="post">
{% csrf_token %} {% csrf_token %}
<input type="submit" value="Delete movie" class="btn btn-danger"> <input type="submit" value="Delete movie" class="btn btn-danger">
@@ -13,6 +14,8 @@
</div> </div>
<div> <div>
<label class="form-label" for="suggested_by" name="suggested_by">Suggested by</label> <label class="form-label" for="suggested_by" name="suggested_by">Suggested by</label>
<div class="row">
<div class="col-auto">
<select class="form-select" id="suggested_by" name="suggested_by"> <select class="form-select" id="suggested_by" name="suggested_by">
{% if request.user.is_staff %} {% if request.user.is_staff %}
{% for user in users %} {% for user in users %}
@@ -23,6 +26,32 @@
{% endif %} {% endif %}
</select> </select>
</div> </div>
</div>
</div>
<div>
<label class="form-label" for="imdb_id">IMDB id</label>
<div class="row">
<div class="col-auto">
<input class="form-control" id="imdb_id" name="imdb_id" {% if movie.imdb_id %}value="{{movie.imdb_id}}"{%endif%}>
<div class="form-text">ID like <code>tt5580390</code> or whole URL.</div>
</div>
<div class="col-3">
<a class="btn btn-secondary" href="https://www.imdb.com/find/?q={{movie.name}}" referrerpolicy="no-referrer" target="_blank">Search</a>
</div>
</div>
</div>
<div>
<label class="form-label" for="csfd_id">ČSFD id</label>
<div class="row">
<div class="col-auto">
<input class="form-control" id="csfd_id" name="csfd_id" {% if movie.csfd_id %}value="{{movie.csfd_id}}"{%endif%}>
<div class="form-text">ID like <code>277495</code> or whole URL.</div>
</div>
<div class="col-3">
<a class="btn btn-secondary" href="https://www.csfd.cz/hledat/?q={{movie.name}}" referrerpolicy="no-referrer" target="_blank">Search</a>
</div>
</div>
</div>
<input type="submit" value="Submit" class="btn btn-primary"> <input type="submit" value="Submit" class="btn btn-primary">
</form> </form>
</div> </div>
+6 -2
View File
@@ -2,18 +2,22 @@
{% block content %} {% block content %}
<div class="container-lg"> <div class="container-lg">
<h1>Watchlist</h1> <h1>Watchlist</h1>
{% if object_list %}
<ul> <ul>
{% for movie in object_list %} {% for movie in object_list %}
<li><a href="{% url 'watchlist:detail' movie.id %}">{{movie.name}}</a> &mdash; {{movie.score}}</li> <li>{% if movie.watched %}<i>{% endif %}<a href="{% url 'watchlist:detail' movie.id %}">{{movie.name}}</a>{% if movie.watched %}</i>{% endif %}{% if request.user.is_authenticated and movie not in voted_movies %}<sup>*</sup>{% endif %} &mdash; {{movie.score}}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %}
<p>No movies yet.</p>
{% endif %}
{% if can_add_movie %} {% if can_add_movie %}
<h2>Submit new movie</h2> <h2>Submit new movie</h2>
<form action="{% url 'watchlist:submit' %}" method="post"> <form action="{% url 'watchlist:submit' %}" method="post">
{% csrf_token %} {% csrf_token %}
<label class="form-label" for="name">Movie name</label> <label class="form-label" for="name">Movie name</label>
<input class="form-control" type="text" id="name" name="name" required> <input class="form-control" type="text" id="name" name="name" required>
<input class="btn btn-primary" type="submit" value="Sumbit"> <input class="btn btn-primary" type="submit" value="Submit">
</form> </form>
{% endif %} {% endif %}
</div> </div>
+9 -2
View File
@@ -3,7 +3,10 @@
<div class="container-lg"> <div class="container-lg">
<h1 class="display-1">{{ movie.name }}</h1> <h1 class="display-1">{{ movie.name }}</h1>
<p class="text-secondary">Suggested by: {{movie.suggested_by.username}}</p> <p class="text-secondary">Suggested by: {{movie.suggested_by.username}}</p>
{% if request.user.is_staff or movie.suggested_by == request.user %}<div class="mb-2"><a class="btn btn-sm btn-danger" href="{% url 'watchlist:edit' movie.id %}">Edit</a></div>{% endif %} <div class="mb-2">
{% if request.user.is_staff or movie.suggested_by == request.user %}<a class="btn btn-sm btn-danger" href="{% url 'watchlist:edit' movie.id %}">Edit</a>{% endif %}
<a class="btn btn-sm btn-secondary" href="{% if movie.imdb_id %}https://www.imdb.com/title/{{movie.imdb_id}}/{%else%}https://www.imdb.com/find/?q={{movie.name}}{%endif%}" referrerpolicy="no-referrer" target="_blank">IMDB{% if movie.imdb_id == '' %}*{%endif%}</a>
<a class="btn btn-sm btn-secondary" href="{% if movie.csfd_id %}https://www.csfd.cz/film/{{movie.csfd_id}}{%else%}https://www.csfd.cz/hledat/?q={{movie.name}}{%endif%}" referrerpolicy="no-referrer" target="_blank">ČSFD{% if movie.csfd_id == '' %}*{%endif%}</a></div>
<h2>Votes</h2> <h2>Votes</h2>
{% if votes|length == 0 %} {% if votes|length == 0 %}
<p>Nobody voted yet, be first...</p> <p>Nobody voted yet, be first...</p>
@@ -11,7 +14,7 @@
<p>Total score: {{ movie.score }}, seen by: {{ movie.seen_score }}. <p>Total score: {{ movie.score }}, seen by: {{ movie.seen_score }}.
<ul> <ul>
{% for vote in votes %} {% for vote in votes %}
<li>{{vote.user.username}} {% if vote.seen %}(seen){% endif %} &ndash; {% if vote.vote == 1 %}👍{% elif vote.vote == 0 %}No opinion{% elif vote.vote == -1 %}👎{%endif%}</li> <li>{{vote.user.username}} {% if vote.seen %}(seen){% endif %} &ndash; {% if vote.vote == 1 %}👍{% elif vote.vote == 0 %}No opinion{% elif vote.vote == -1 %}👎{%endif%}{% if vote.comment != "" %} &ndash; {{vote.comment}}{% endif %}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
@@ -30,6 +33,10 @@
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="seen" name="seen" {% if user_vote and user_vote.seen %} checked {%endif%}><label class="form-check-label" for="seen">I saw this movie already</label><br> <input class="form-check-input" type="checkbox" id="seen" name="seen" {% if user_vote and user_vote.seen %} checked {%endif%}><label class="form-check-label" for="seen">I saw this movie already</label><br>
</div>
<div>
<label class="form-check-label" for="comment">Comment</label>
<input class="form-control" type="text" id="comment" name="comment" {% if user_vote and user_vote.comment %} value="{{user_vote.comment}}"{% endif %}>
</div> </div>
<input class="btn btn-primary" type="submit" value="Submit"> <input class="btn btn-primary" type="submit" value="Submit">
</form> </form>
+5 -1
View File
@@ -3,7 +3,8 @@ from django.contrib import admin
from . import models from . import models
class MovieVoteInline(admin.StackedInline): class MovieVoteInline(admin.StackedInline):
model = models.MovieVote() model = models.MovieVote
extra = 0
class MovieAdmin(admin.ModelAdmin): class MovieAdmin(admin.ModelAdmin):
fields = [ fields = [
@@ -11,6 +12,9 @@ class MovieAdmin(admin.ModelAdmin):
] ]
readonly_fields = ("score",) readonly_fields = ("score",)
list_display = ["name", "watched", "suggested_by", "score"] list_display = ["name", "watched", "suggested_by", "score"]
inlines = [
MovieVoteInline
]
@admin.display(description="Score") @admin.display(description="Score")
def score(self, instance): def score(self, instance):
+1 -1
View File
@@ -6,4 +6,4 @@ class MovieEditForm(forms.ModelForm):
class Meta: class Meta:
model = models.Movie model = models.Movie
fields = ["name", "suggested_by", "watched"] fields = ["name", "suggested_by", "watched", "imdb_id", "csfd_id"]
@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-24 20:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('watchlist', '0004_alter_movie_options'),
]
operations = [
migrations.AddField(
model_name='movievote',
name='comment',
field=models.TextField(null=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-02-10 12:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('watchlist', '0005_movievote_comment'),
]
operations = [
migrations.AddField(
model_name='movie',
name='csfd_id',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='movie',
name='imdb_id',
field=models.CharField(blank=True, max_length=32),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-02-10 13:26
from django.db import migrations, models
import watchlist.models
class Migration(migrations.Migration):
dependencies = [
('watchlist', '0006_movie_csfd_id_movie_imdb_id'),
]
operations = [
migrations.AlterField(
model_name='movie',
name='csfd_id',
field=models.CharField(blank=True, default='', max_length=32, validators=[watchlist.models.validate_imdb_id]),
preserve_default=False,
),
migrations.AlterField(
model_name='movie',
name='imdb_id',
field=models.CharField(blank=True, max_length=32, validators=[watchlist.models.validate_csfd_id]),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.1.5 on 2023-02-17 14:06
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('watchlist', '0007_alter_movie_csfd_id_alter_movie_imdb_id'),
]
operations = [
migrations.AlterField(
model_name='movievote',
name='comment',
field=models.TextField(blank=True, default=''),
preserve_default=False,
),
]
+14
View File
@@ -1,8 +1,19 @@
import re
from functools import reduce from functools import reduce
from django.db import models from django.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib import admin from django.contrib import admin
IMDB_ID_RE = re.compile(r'(?P<id>tt\d{7,})')
CSFD_ID_RE = re.compile(r'(?P<id>[1-9]\d*)')
def validate_imdb_id(v: str) -> bool:
return IMDB_ID_RE.match(v)
def validate_csfd_id(v: str) -> bool:
return CSFD_ID_RE.match(v)
class Movie(models.Model): class Movie(models.Model):
class Meta: class Meta:
@@ -13,6 +24,8 @@ class Movie(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
suggested_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) suggested_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
watched = models.BooleanField(default=False) watched = models.BooleanField(default=False)
imdb_id = models.CharField(max_length=32, blank=True, validators=[validate_csfd_id])
csfd_id = models.CharField(max_length=32, blank=True, validators=[validate_imdb_id])
@property @property
def score(self): def score(self):
@@ -36,6 +49,7 @@ class MovieVote(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.CASCADE)
vote = models.IntegerField(choices=Vote.choices, default=Vote.NOVOTE) vote = models.IntegerField(choices=Vote.choices, default=Vote.NOVOTE)
seen = models.BooleanField(default=False, null=True) seen = models.BooleanField(default=False, null=True)
comment = models.TextField(blank=True)
def __str__(self): def __str__(self):
return f"{self.user.username}'s vote for {self.movie.name}" return f"{self.user.username}'s vote for {self.movie.name}"
-18
View File
@@ -1,18 +0,0 @@
from rest_framework import serializers
from . import models
class MovieSerializer(serializers.HyperlinkedModelSerializer):
suggested_by = serializers.ReadOnlyField(source="suggested_by.username")
class Meta:
model = models.Movie
fields = ["url", "name", "watched", "suggested_by", "score"]
# class VoteSerializer(serializers.Serializer):
-3
View File
@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.
View File
+78
View File
@@ -0,0 +1,78 @@
from django.test import TestCase
from watchlist.models import IMDB_ID_RE, CSFD_ID_RE
class IMDBIDTest(TestCase):
def test_valid_imdb_id(self):
valid = [
"tt0000000",
"tt1234567",
"tt4975722",
"tt3581920"
]
for testId in valid:
with self.subTest(testId=testId):
self.assertTrue(IMDB_ID_RE.match(testId))
def test_invalid_imdb_id(self):
invalid = [
"tt",
"tt1",
"1234567"
"tt123456",
"-tt1234567",
""
]
for testId in invalid:
with self.subTest(testId=testId):
self.assertFalse(IMDB_ID_RE.match(testId))
def test_url_extraction(self):
urls = [
("https://www.imdb.com/title/tt4925292/?ref_=hm_rvi_tt_i_1", "tt4925292"),
("https://www.imdb.com/title/tt5580390/", "tt5580390"),
]
for url, result in urls:
with self.subTest(url=url, result=result):
m = IMDB_ID_RE.search(url)
self.assertTrue(m)
self.assertEqual(m.group(), result)
class CSFDIDTest(TestCase):
def test_valid_csfd_id(self):
valid = [
"1",
"123",
"969361"
]
for testId in valid:
with self.subTest(testId=testId):
self.assertTrue(CSFD_ID_RE.match(testId))
def test_invalid_csfd_id(self):
invalid = [
"abc",
"-1",
"0",
"tt0000000",
"",
]
for testId in invalid:
with self.subTest(testId=testId):
self.assertFalse(CSFD_ID_RE.match(testId))
def test_url_extraction(self):
urls = [
("https://www.csfd.cz/film/1-predevsim-nikomu-neublizim/recenze/", "1"),
("https://www.csfd.cz/film/969361-velryba/prehled/", "969361"),
("https://www.csfd.cz/film/370706-daredevil/galerie/?page=20", "370706"),
("https://www.csfd.cz/film/370706", "370706")
]
for url, result in urls:
with self.subTest(url=url, result=result):
m = CSFD_ID_RE.search(url)
self.assertTrue(m)
self.assertEqual(m.group(), result)
+175
View File
@@ -0,0 +1,175 @@
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User, Permission
from django.contrib.auth.hashers import make_password
from watchlist.models import Movie, MovieVote
def create_user(name="user", is_staff=False):
user = User.objects.create(username=name, password=make_password("dummy"))
user.user_permissions.add(Permission.objects.get(codename="add_movie"))
user.save()
return user
def create_movie(name="Test movie", added_by="user"):
return Movie.objects.create(name=name, suggested_by=User.objects.filter(username=added_by).first(), watched=False)
class IndexViewTests(TestCase):
def setUp(self):
self.user = create_user("user")
def test_no_movies(self):
"""Tests the index page with no movies"""
response = self.client.get(reverse('watchlist:index'))
self.assertQuerysetEqual(response.context["object_list"], [])
self.assertContains(response, "No movies yet.")
self.assertNotContains(response, "Submit new movie")
def test_no_movies_logged_in(self):
"""Tests the index page with no movies, while the user is logged in."""
self.client.login(username="user", password="dummy")
response = self.client.get(reverse('watchlist:index'))
self.assertQuerysetEqual(response.context["object_list"], [])
self.assertContains(response, "No movies yet.")
self.assertContains(response, "Submit new movie")
def test_with_movie(self):
"""Tests that the index page shows a movie, hides it if it watched, but shows
it with proper filter"""
create_movie()
with self.subTest(watched=False):
response = self.client.get(reverse('watchlist:index'))
self.assertQuerysetEqual(response.context["object_list"], Movie.objects.all())
self.assertContains(response, "Test movie")
movie = Movie.objects.first()
movie.watched = True
movie.save()
with self.subTest(watched=True, filter=False):
response = self.client.get(reverse('watchlist:index'))
self.assertQuerysetEqual(response.context["object_list"], [])
self.assertNotContains(response, "Test movie")
with self.subTest(watched=True, filter=True):
response = self.client.get(reverse('watchlist:index',) + "?watched")
self.assertQuerysetEqual(response.context["object_list"], Movie.objects.all())
self.assertContains(response, "Test movie")
def test_unvoted(self):
"""Test that the index shows an asterisk with movies that the user hasn't voted on yet"""
create_movie()
self.client.login(username="user",password="dummy")
response = self.client.get(reverse('watchlist:index'))
self.assertContains(response, "Test movie</a><sup>*</sup>")
def test_voted(self):
"""Test that the index doesn't show an asterisk with movies the has voted on."""
movie = create_movie()
MovieVote.objects.create(movie=movie, user=self.user, vote=1)
self.client.login(username="user",password="dummy")
response = self.client.get(reverse('watchlist:index'))
self.assertContains(response, "Test movie</a>")
self.assertNotContains(response, "Test movie</a><sup>*</sup>")
def test_movie_sorting(self):
"""Test various methods of movie sorting"""
m1 = create_movie(name="ZZZ: A movie test")
m2 = create_movie(name="Test movie 2")
tests = [
('?sort=id', [m1,m2]),
('?sort=-id', [m2,m1]),
('?sort=name', [m2,m1]),
('?sort=-name', [m1,m2]),
]
for param, qs in tests:
with self.subTest(param=param, qs=qs):
response = self.client.get(reverse('watchlist:index') + param)
self.assertQuerysetEqual(response.context["object_list"], qs)
self.assertContains(response, m1.name)
self.assertContains(response, m2.name)
class MovieDetailViewTests(TestCase):
def setUp(self):
self.user = create_user("user")
def test_detail_logged_out(self):
m = create_movie()
response = self.client.get(reverse('watchlist:detail', args=(m.id,)))
self.assertContains(response, m.name)
self.assertEqual(response.context["movie"], m)
self.assertNotContains(response, "Edit")
def test_detail_logged_in(self):
m = create_movie()
self.client.login(username="user", password="dummy")
response = self.client.get(reverse('watchlist:detail', args=(m.id,)))
self.assertContains(response, m.name)
self.assertEqual(response.context["movie"], m)
self.assertContains(response, "Edit")
def test_no_movie(self):
response = self.client.get(reverse('watchlist:detail', args=(1,)))
self.assertEqual(response.status_code, 404)
class VoteTests(TestCase):
def setUp(self):
self.user = create_user("user")
def test_no_user_voting(self):
m = create_movie()
response = self.client.post(reverse('watchlist:vote', args=(m.id,)), data={"vote": 1, "seen": True})
self.assertRedirects(response, "/accounts/login/?next=" + response.request["PATH_INFO"], fetch_redirect_response=False)
def test_user_voting(self):
m = create_movie()
score = m.score
self.client.login(username="user", password="dummy")
for vote in [1, 0, -1]:
for seen in True, False:
with self.subTest(vote=vote, seen=seen):
response = self.client.post(reverse('watchlist:vote', args=(m.id,)), data={"vote": str(vote), "seen": True})
self.assertRedirects(response, reverse('watchlist:index'))
self.assertEqual(m.score, score + vote)
def test_invalid_votes(self):
m = create_movie()
score = m.score
self.client.login(username="user", password="dummy")
for vote in [2,3,4,-5,0.5,'abc','']:
with self.subTest(vote=vote):
response = self.client.post(reverse('watchlist:vote', args=(m.id,)), data={"vote": str(vote), "seen": True})
self.assertEqual(response.status_code, 400)
self.assertEqual(m.score, score)
with self.subTest(vote="empty"):
response = self.client.post(reverse('watchlist:vote', args=(m.id,)), data={"seen": True})
self.assertEqual(response.status_code, 400)
self.assertEqual(m.score, score)
def test_seen(self):
m = create_movie()
self.client.login(username="user", password="dummy")
for seen in (True, False) * 2:
with self.subTest(seen=seen):
data = {"vote": "0", "seen": "on"} if seen else {"vote": "0"}
response = self.client.post(reverse('watchlist:vote', args=(m.id,)), data=data)
self.assertRedirects(response, reverse('watchlist:index'))
mv = m.movievote_set.get(user=self.user)
self.assertEqual(mv.seen, seen)
def test_comment(self):
m = create_movie()
self.client.login(username="user", password="dummy")
for comment in ["aaa", "", "TEST"]:
with self.subTest(comment=comment):
response = self.client.post(reverse('watchlist:vote', args=(m.id,)), data={"vote": "0", "comment": comment})
mv = m.movievote_set.get(user=self.user)
self.assertEqual(mv.comment, comment)
with self.subTest(comment=None):
response = self.client.post(reverse('watchlist:vote', args=(m.id,)), data={"vote": "0"})
mv = m.movievote_set.get(user=self.user)
self.assertEqual(mv.comment, "")
-4
View File
@@ -1,10 +1,6 @@
from django.urls import path from django.urls import path
from rest_framework import routers
from . import views from . import views
api_router = routers.DefaultRouter()
api_router.register(r'movie', views.MovieViewSet)
app_name = "watchlist" app_name = "watchlist"
urlpatterns = [ urlpatterns = [
path('', views.IndexView.as_view(), name="index"), path('', views.IndexView.as_view(), name="index"),
+43 -21
View File
@@ -5,35 +5,30 @@ from django.urls import reverse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from . import serializers, models
class MovieViewSet(viewsets.ModelViewSet):
queryset = models.Movie.objects.order_by('id').all()
serializer_class = serializers.MovieSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
# @action(detail=True, methods=["POST"])
# def vote(self, request, pk=None):
# movie = self.get_object()
# vote = request.date.get("vote", 0)
from . import models
class IndexView(generic.ListView): class IndexView(generic.ListView):
template_name = "watchlist/index.html" template_name = "watchlist/index.html"
model = models.Movie model = models.Movie
def get_queryset(self): def get_queryset(self):
return models.Movie.objects.filter(watched=False).order_by('id').all() qs = models.Movie.objects
# Filter
if self.request.GET.get("watched", False) == False:
qs = qs.filter(watched=False)
# Sort
order = self.request.GET.get("sort", "score")
if order in ('id', '-id', 'name', '-name'):
qs = qs.order_by(order).all()
elif order == "score" or order == "-score":
qs = sorted(qs.all(), key=lambda x: x.score, reverse=True if order == "score" else False)
return qs
def get_context_data(self): def get_context_data(self):
context = super().get_context_data() context = super().get_context_data()
context['can_add_movie'] = self.request.user.has_perm("watchlist.add_movie") context['can_add_movie'] = self.request.user.has_perm("watchlist.add_movie")
context['voted_movies'] = models.Movie.objects.filter(movievote__user=self.request.user).all() if self.request.user.is_authenticated else []
return context return context
class DetailView(generic.DetailView): class DetailView(generic.DetailView):
@@ -56,10 +51,15 @@ def vote(request, pk):
user_vote = movie.movievote_set.filter(user=request.user).first() user_vote = movie.movievote_set.filter(user=request.user).first()
if user_vote is None: if user_vote is None:
user_vote = models.MovieVote(movie=movie, user=request.user) user_vote = models.MovieVote(movie=movie, user=request.user)
if not request.POST.get("vote", None) in ("1", "0", "-1"):
return HttpResponseBadRequest("Invalid vote.")
user_vote.vote = request.POST['vote'] user_vote.vote = request.POST['vote']
user_vote.seen = request.POST.get('seen', False) == "on" user_vote.seen = request.POST.get('seen', False) == "on"
comment = request.POST.get('comment', '').strip()
if comment != '' or user_vote.comment != "":
user_vote.comment = comment
user_vote.save() user_vote.save()
return HttpResponseRedirect(reverse('watchlist:detail', args=(pk,))) return HttpResponseRedirect(reverse('watchlist:index'))
class EditView(generic.DetailView): class EditView(generic.DetailView):
model = models.Movie model = models.Movie
@@ -71,6 +71,7 @@ class EditView(generic.DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["users"] = User.objects.all() if self.request.user.has_perm("watchlist.moderate_movies") else None context["users"] = User.objects.all() if self.request.user.has_perm("watchlist.moderate_movies") else None
context["error"] = self.request.GET.get("error", None)
return context return context
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@@ -84,7 +85,28 @@ class EditView(generic.DetailView):
if not self.can_edit_movie(request): if not self.can_edit_movie(request):
return HttpResponseForbidden("You cannot edit this object.") return HttpResponseForbidden("You cannot edit this object.")
if "name" in request.POST: if "name" in request.POST:
self.object.name = request.POST["name"] name = request.POST["name"]
if name == '':
return HttpResponseRedirect(reverse('watchlist:edit', args=(kwargs["pk"],)) + "?error=Invalid%20name")
self.object.name = name
if "imdb_id" in request.POST:
if request.POST["imdb_id"] == '':
if self.object.imdb_id != '':
self.object.imdb_id = ''
else:
match = models.IMDB_ID_RE.search(request.POST["imdb_id"])
if not match:
return HttpResponseRedirect(reverse('watchlist:edit', args=(kwargs["pk"],)) + "?error=Invalid%20IMDB%20ID")
self.object.imdb_id = match.group()
if "csfd_id" in request.POST:
if request.POST["csfd_id"] == '':
if self.object.csfd_id != '':
self.object.csfd_id = ''
else:
match = models.CSFD_ID_RE.search(request.POST["csfd_id"])
if not match:
return HttpResponseRedirect(reverse('watchlist:edit', args=(kwargs["pk"],)) + "?error=Invalid%20CSFD%20ID")
self.object.csfd_id = match.group()
if "suggested_by" in request.POST: if "suggested_by" in request.POST:
if request.user.has_perm('watchlist.moderate_movies'): if request.user.has_perm('watchlist.moderate_movies'):
new_suggestor = User.objects.filter(username=request.POST["suggested_by"]).first() new_suggestor = User.objects.filter(username=request.POST["suggested_by"]).first()
@@ -105,7 +127,7 @@ def submit(request):
return HttpResponseForbidden("You can't add new movies.") return HttpResponseForbidden("You can't add new movies.")
movie = models.Movie(name=request.POST["name"], suggested_by=request.user, watched=False) movie = models.Movie(name=request.POST["name"], suggested_by=request.user, watched=False)
movie.save() movie.save()
return HttpResponseRedirect(reverse("watchlist:index")) return HttpResponseRedirect(reverse("watchlist:edit", args=(movie.id,)))
@login_required @login_required
@require_POST @require_POST