Compare commits

...

11 Commits

Author SHA1 Message Date
michal a48df0f44d Filter out inactive users
continuous-integration/drone Build is passing
2023-05-18 22:28:51 +02:00
michal 9230c35be6 Make install quiet
continuous-integration/drone/push Build is passing
2023-02-17 15:44:04 +01:00
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
15 changed files with 365 additions and 19 deletions
+11
View File
@@ -3,6 +3,17 @@ type: docker
name: default
steps:
- name: test
image: python:3.10
commands:
- pip3 install poetry --quiet
- poetry install --no-root --quiet
- 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
image: caprover/cli-caprover:2.2.3
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
Generated
+13 -1
View File
@@ -181,6 +181,18 @@ files = [
{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]]
name = "tzdata"
version = "2022.7"
@@ -223,4 +235,4 @@ brotli = ["Brotli"]
[metadata]
lock-version = "2.0"
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"
[tool.poetry.group.dev.dependencies]
tblib = "^1.7.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
+3 -1
View File
@@ -1,4 +1,6 @@
{% extends "base.html" %}
{% block content %}
<h1>Nothing here...</h1>
<div class="container-lg">
<h1>Nothing here...</h1>
</div>
{% endblock %}
+7 -3
View File
@@ -1,19 +1,23 @@
{% extends 'base.html' %}
{% block content %}
<div class="container-lg">
<h1>Watchlist</h1>
<h1>Watchlist</h1>
{% if object_list %}
<ul>
{% for movie in object_list %}
<li><a href="{% url 'watchlist:detail' movie.id %}">{{movie.name}}</a>{% if request.user.is_authenticated and movie not in voted_movies %}<sup>*</sup>{% endif %} &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 %}
</ul>
{% else %}
<p>No movies yet.</p>
{% endif %}
{% if can_add_movie %}
<h2>Submit new movie</h2>
<form action="{% url 'watchlist:submit' %}" method="post">
{% csrf_token %}
<label class="form-label" for="name">Movie name</label>
<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>
{% endif %}
</div>
+1 -1
View File
@@ -14,7 +14,7 @@
<p>Total score: {{ movie.score }}, seen by: {{ movie.seen_score }}.
<ul>
{% 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%}{% if vote.comment is not None %} &ndash; {{vote.comment}}{% 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 %}
</ul>
{% endif %}
+5 -1
View File
@@ -3,7 +3,8 @@ from django.contrib import admin
from . import models
class MovieVoteInline(admin.StackedInline):
model = models.MovieVote()
model = models.MovieVote
extra = 0
class MovieAdmin(admin.ModelAdmin):
fields = [
@@ -11,6 +12,9 @@ class MovieAdmin(admin.ModelAdmin):
]
readonly_fields = ("score",)
list_display = ["name", "watched", "suggested_by", "score"]
inlines = [
MovieVoteInline
]
@admin.display(description="Score")
def score(self, instance):
@@ -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,
),
]
+4 -4
View File
@@ -6,7 +6,7 @@ from django.contrib.auth.models import User
from django.contrib import admin
IMDB_ID_RE = re.compile(r'(?P<id>tt\d{7,})')
CSFD_ID_RE = re.compile(r'(?P<id>\d+)')
CSFD_ID_RE = re.compile(r'(?P<id>[1-9]\d*)')
def validate_imdb_id(v: str) -> bool:
return IMDB_ID_RE.match(v)
@@ -29,11 +29,11 @@ class Movie(models.Model):
@property
def score(self):
return reduce(lambda result,v: result+v.vote, self.movievote_set.all(), 0)
return reduce(lambda result,v: result+v.vote, self.movievote_set.filter(user__is_active=True).all(), 0)
@property
def seen_score(self):
return reduce(lambda result,v: result+int(v.seen), self.movievote_set.all(), 0)
return reduce(lambda result,v: result+int(v.seen), self.movievote_set.filter(user__is_active=True).all(), 0)
def __str__(self):
return self.name
@@ -49,7 +49,7 @@ class MovieVote(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
vote = models.IntegerField(choices=Vote.choices, default=Vote.NOVOTE)
seen = models.BooleanField(default=False, null=True)
comment = models.TextField(null=True)
comment = models.TextField(blank=True)
def __str__(self):
return f"{self.user.username}'s vote for {self.movie.name}"
-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, "")
+6 -5
View File
@@ -1,3 +1,4 @@
from django.db.models import Q
from django.http import HttpResponseRedirect, HttpResponseBadRequest, HttpResponseForbidden
from django.views import generic
from django.views.decorators.http import require_http_methods, require_safe, require_POST
@@ -36,7 +37,7 @@ class DetailView(generic.DetailView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
votes = self.object.movievote_set.all()
votes = self.object.movievote_set.filter(user__is_active=True).all()
user_vote = None
if self.request.user.is_authenticated:
user_vote = votes.filter(user=self.request.user).first()
@@ -51,12 +52,12 @@ def vote(request, pk):
user_vote = movie.movievote_set.filter(user=request.user).first()
if user_vote is None:
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.seen = request.POST.get('seen', False) == "on"
comment = request.POST.get('comment', '').strip()
if comment != '' or user_vote.comment is not None:
if comment == '':
comment = None
if comment != '' or user_vote.comment != "":
user_vote.comment = comment
user_vote.save()
return HttpResponseRedirect(reverse('watchlist:index'))
@@ -70,7 +71,7 @@ class EditView(generic.DetailView):
def get_context_data(self, **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.filter(Q(is_active=True) | Q(id=self.object.suggested_by.id)).all() if self.request.user.has_perm("watchlist.moderate_movies") else None
context["error"] = self.request.GET.get("error", None)
return context