Compare commits

...

36 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
michal cdd49a2c68 Add CSRF_TRUSTED_ORIGINS setting
continuous-integration/drone/push Build is passing
2023-01-17 22:37:37 +01:00
michal 87b1ca2382 Add USE_X_FORWARDED_HOST setting
continuous-integration/drone/push Build is passing
2023-01-17 22:35:59 +01:00
michal c3490e8a81 Add support for some security settings
continuous-integration/drone/push Build is passing
2023-01-17 22:30:11 +01:00
michal e2efba4cc2 Add deploy CI
continuous-integration/drone/push Build is passing
2023-01-17 21:47:43 +01:00
michal 80a09c8907 Don't show watched movies in watchlist 2023-01-17 19:25:18 +01:00
michal d0494c8370 Add support for serving static files 2023-01-17 19:02:23 +01:00
michal eecd62f5b2 Add favicon 2023-01-16 17:21:35 +01:00
michal 2c1e71db77 Fix settings 2023-01-16 17:19:04 +01:00
michal 821ba87de6 Remove pyc files from repo 2023-01-16 16:52:31 +01:00
michal 823470e2f2 First working version 2023-01-15 18:32:05 +01:00
53 changed files with 1631 additions and 24 deletions
+160
View File
@@ -0,0 +1,160 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
+31
View File
@@ -0,0 +1,31 @@
kind: pipeline
type: docker
name: default
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
image: caprover/cli-caprover:2.2.3
commands:
- caprover deploy
environment:
CAPROVER_URL:
from_secret: caprover_url
CAPROVER_APP:
from_secret: caprover_app
CAPROVER_APP_TOKEN:
from_secret: app_token
CAPROVER_BRANCH: main
when:
branch:
- main
+160
View File
@@ -0,0 +1,160 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
+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
+19
View File
@@ -0,0 +1,19 @@
FROM python:3.11
EXPOSE 8000
WORKDIR /app
COPY poetry.lock pyproject.toml ./
RUN apt update && apt install -y -qq \
python3-dev \
default-libmysqlclient-dev \
build-essential \
&& \
pip3 install \
poetry==1.3.0 \
&& \
poetry add mysqlclient && \
poetry install
COPY . /app
CMD ["bash", "/app/start.sh" ]
View File
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuthConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'auth'
+11
View File
@@ -0,0 +1,11 @@
from django import forms
class LoginForm(forms.Form):
username = forms.CharField(label="Username", max_length=100, required=True, strip=True,
widget=forms.TextInput(attrs={"class": "form-control"})
)
password = forms.CharField(label="Password", max_length=100, required=True,
widget=forms.PasswordInput(attrs={"class": "form-control"})
)
View File
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+8
View File
@@ -0,0 +1,8 @@
from django.urls import path
from . import views
app_name = "auth"
urlpatterns = [
path('login', views.LoginView.as_view(), name="login"),
path('logout', views.LogoutView.as_view(), name="logout"),
]
+51
View File
@@ -0,0 +1,51 @@
from django.shortcuts import render
from django.urls import reverse
from django.http import HttpResponseRedirect
from django.contrib.auth import authenticate, login, logout
from django.views import generic
from .forms import LoginForm
class LoginView(generic.TemplateView):
template_name = "auth/login.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["form"] = LoginForm()
if "next" in self.request.GET:
context["next"] = self.request.GET["next"]
return context
def get(self, request, *args, **kwargs):
if request.user.is_authenticated:
return HttpResponseRedirect(request.GET.get("next","/"))
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
if request.user.is_authenticated:
return HttpResponseRedirect(request.POST.get("next","/"))
form = LoginForm(request.POST)
if not form.is_valid():
return HttpResponseRedirect(reverse("auth:login"))
user = authenticate(request, username=form.cleaned_data["username"], password=form.cleaned_data["password"])
if user is not None:
login(request, user)
return HttpResponseRedirect(request.GET.get("next", "/"))
else:
return HttpResponseRedirect(reverse("auth:login"))
class LogoutView(generic.RedirectView):
def get_redirect_url(self):
return self.request.GET.get("next", "/")
def get(self, request, *args, **kwargs):
logout(request)
return super().get(request, *args, **kwargs)
def logout_view(request):
logout(request)
redirect_to = request.GET.get("redirect_to", "/")
return HttpResponseRedirect(redirect_to)
+4
View File
@@ -0,0 +1,4 @@
{
"schemaVersion": 2,
"dockerfilePath": "./Dockerfile"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+34 -22
View File
@@ -1,36 +1,40 @@
""" """
Django settings for movieclub project. Django settings for movieclub project.
Generated by 'django-admin startproject' using Django 4.1.5.
For more information on this file, see
https://docs.djangoproject.com/en/4.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.1/ref/settings/
""" """
import environ
import os
from pathlib import Path from pathlib import Path
env = environ.Env(
DEBUG=(bool, False),
LANGUAGE_CODE=(str, "en-us"),
TIME_ZONE=(str, "UTC")
)
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
environ.Env.read_env(os.path.join(BASE_DIR, '.env'))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-dkxk-x5l6s(8tt89-gwq+u5)o-qovem0=@a^00=h=*3r+25%^g' SECRET_KEY = env("SECRET_KEY")
SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", False)
CSRF_COOKIE_SAMESITE = env("CSRF_COOKIE_SAMESITE", default="Lax")
CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=False)
CSRF_TRUSTED_ORIGINS = env.list("CSRF_TRUSTED_ORIGINS", default=[])
SECURE_PROXY_SSL_HEADER = env.tuple("SECURE_PROXY_SSL_HEADER", default=None)
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 = True DEBUG = env('DEBUG')
ALLOWED_HOSTS = []
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'watchlist.apps.WatchlistConfig',
'rest_framework',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
@@ -39,8 +43,14 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
] ]
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100
}
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
"whitenoise.middleware.WhiteNoiseMiddleware",
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
@@ -54,7 +64,9 @@ ROOT_URLCONF = 'movieclub.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [], 'DIRS': [
BASE_DIR / "templates"
],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@@ -74,10 +86,7 @@ WSGI_APPLICATION = 'movieclub.wsgi.application'
# https://docs.djangoproject.com/en/4.1/ref/settings/#databases # https://docs.djangoproject.com/en/4.1/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': env.db()
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
} }
@@ -105,7 +114,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC' TIME_ZONE = 'Europe/Prague'
USE_I18N = True USE_I18N = True
@@ -115,7 +124,10 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.1/howto/static-files/ # https://docs.djangoproject.com/en/4.1/howto/static-files/
STATICFILES_DIRS = [ BASE_DIR / "static"]
STATIC_ROOT = "deploy/static/"
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATICFILES_STORAGE = env("STATICFILES_STORAGE", default="whitenoise.storage.CompressedStaticFilesStorage")
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
+14 -1
View File
@@ -14,8 +14,21 @@ Including another URLconf
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import include, path, reverse
from django.views.generic.base import RedirectView
import auth.urls
import watchlist.urls
urlpatterns = [ urlpatterns = [
path('', RedirectView.as_view(url="/watchlist/"), name="home"),
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('auth/', include(auth.urls)),
path('watchlist/', include(watchlist.urls)),
# path('openapi', schemas.get_schema_view(
# title="Movieclub",
# description="",
# version="0.1.0"
# )),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
] ]
Generated
+173 -1
View File
@@ -36,6 +36,139 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
argon2 = ["argon2-cffi (>=19.1.0)"] argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"] bcrypt = ["bcrypt"]
[[package]]
name = "django-environ"
version = "0.9.0"
description = "A package that allows you to utilize 12factor inspired environment variables to configure your Django application."
category = "main"
optional = false
python-versions = ">=3.4,<4"
files = [
{file = "django-environ-0.9.0.tar.gz", hash = "sha256:bff5381533056328c9ac02f71790bd5bf1cea81b1beeb648f28b81c9e83e0a21"},
{file = "django_environ-0.9.0-py2.py3-none-any.whl", hash = "sha256:f21a5ef8cc603da1870bbf9a09b7e5577ab5f6da451b843dbcc721a7bca6b3d9"},
]
[package.extras]
develop = ["coverage[toml] (>=5.0a4)", "furo (>=2021.8.17b43,<2021.9.0)", "pytest (>=4.6.11)", "sphinx (>=3.5.0)", "sphinx-notfound-page"]
docs = ["furo (>=2021.8.17b43,<2021.9.0)", "sphinx (>=3.5.0)", "sphinx-notfound-page"]
testing = ["coverage[toml] (>=5.0a4)", "pytest (>=4.6.11)"]
[[package]]
name = "djangorestframework"
version = "3.14.0"
description = "Web APIs for Django, made easy."
category = "main"
optional = false
python-versions = ">=3.6"
files = [
{file = "djangorestframework-3.14.0-py3-none-any.whl", hash = "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08"},
{file = "djangorestframework-3.14.0.tar.gz", hash = "sha256:579a333e6256b09489cbe0a067e66abe55c6595d8926be6b99423786334350c8"},
]
[package.dependencies]
django = ">=3.0"
pytz = "*"
[[package]]
name = "gunicorn"
version = "20.1.0"
description = "WSGI HTTP Server for UNIX"
category = "main"
optional = false
python-versions = ">=3.5"
files = [
{file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"},
{file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"},
]
[package.dependencies]
setuptools = ">=3.0"
[package.extras]
eventlet = ["eventlet (>=0.24.1)"]
gevent = ["gevent (>=1.4.0)"]
setproctitle = ["setproctitle"]
tornado = ["tornado (>=0.2)"]
[[package]]
name = "pytz"
version = "2022.7"
description = "World timezone definitions, modern and historical"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "pytz-2022.7-py2.py3-none-any.whl", hash = "sha256:93007def75ae22f7cd991c84e02d434876818661f8df9ad5df9e950ff4e52cfd"},
{file = "pytz-2022.7.tar.gz", hash = "sha256:7ccfae7b4b2c067464a6733c6261673fdb8fd1be905460396b97a073e9fa683a"},
]
[[package]]
name = "pyyaml"
version = "6.0"
description = "YAML parser and emitter for Python"
category = "main"
optional = false
python-versions = ">=3.6"
files = [
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
{file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
{file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
{file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"},
{file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"},
{file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"},
{file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"},
{file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"},
{file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"},
{file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"},
{file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
{file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
{file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
{file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
{file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
{file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
{file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
{file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
{file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
{file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
{file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
{file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
]
[[package]]
name = "setuptools"
version = "66.0.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "setuptools-66.0.0-py3-none-any.whl", hash = "sha256:a78d01d1e2c175c474884671dde039962c9d74c7223db7369771fcf6e29ceeab"},
{file = "setuptools-66.0.0.tar.gz", hash = "sha256:bd6eb2d6722568de6d14b87c44a96fac54b2a45ff5e940e639979a3d1792adb6"},
]
[package.extras]
docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.4.3" version = "0.4.3"
@@ -48,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"
@@ -60,7 +205,34 @@ files = [
{file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"}, {file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"},
] ]
[[package]]
name = "uritemplate"
version = "4.1.1"
description = "Implementation of RFC 6570 URI Templates"
category = "main"
optional = false
python-versions = ">=3.6"
files = [
{file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"},
{file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"},
]
[[package]]
name = "whitenoise"
version = "6.3.0"
description = "Radically simplified static file serving for WSGI applications"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "whitenoise-6.3.0-py3-none-any.whl", hash = "sha256:cf8ecf56d86ba1c734fdb5ef6127312e39e92ad5947fef9033dc9e43ba2777d9"},
{file = "whitenoise-6.3.0.tar.gz", hash = "sha256:fe0af31504ab08faa1ec7fc02845432096e40cc1b27e6a7747263d7b30fb51fa"},
]
[package.extras]
brotli = ["Brotli"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "8070a9888290f52ebd017534a68014a5724f63a94dfed4c513488df1f2c31d0f" content-hash = "ca22a0efd5d4acfb0a4a09e8567d0a8c90debca1203b7a80262919d8055366da"
+9
View File
@@ -8,8 +8,17 @@ readme = "README.md"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.10" python = "^3.10"
django = "^4.1.5" django = "^4.1.5"
djangorestframework = "^3.14.0"
uritemplate = "^4.1.1"
pyyaml = "^6.0"
django-environ = "^0.9.0"
gunicorn = "^20.1.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"
Executable
+3
View File
@@ -0,0 +1,3 @@
poetry run ./manage.py migrate
poetry run ./manage.py collectstatic -c --no-input
poetry run gunicorn movieclub.wsgi -b 0.0.0.0:8000
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File
+5
View File
@@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block content %}
<h1>You can't access this resource.</h1>
<p>{{exception}}</p>
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block content %}
<div class="container-lg">
<h1>Nothing here...</h1>
</div>
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
{% for field in form %}
<div class="fieldWrapper">
{{ field.errors }}
{{ field.label_tag }} {{ field }}
</div>
{% endfor %}
+1
View File
@@ -0,0 +1 @@
{% if use_tag %}<{{ tag }}{% include 'django/forms/attrs.html' %}>{{ label }}</{{ tag }}>{% else %}{{ label }}{% endif %}
+18
View File
@@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block content %}
<div class="container container-sm">
<form action="{% url 'auth:login' %}" method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label" for="username">Username:</label>
<input class="form-control" type="text" name="username" required>
</div>
<div class="mb-3">
<label class="form-label" for="password">Password:</label>
<input class="form-control" type="password" name="password" required>
</div>
<input type="submit" class="btn btn-primary" value="Submit">
</form>
</div>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
<title>{% block title %}Movieclub{% endblock %}</title>
<meta name='viewport' content='width=device-width, initial-scale=1'>
<link rel='icon' type="image/webp" href="{% static 'favicon.webp' %}">
<!-- <link rel='stylesheet' type='text/css' media='screen' href='{% static "style/main.css" %}'> -->
<!-- Bootstrap -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">
</head>
<body>
{% block body %}
{% block menu %}
<nav class="navbar navbar-expand-lg bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="/">Movieclub</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-content">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-content">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{% url 'watchlist:index' %}">Watchlist</a>
</li>
<li class="nav-item">
<a class="nav-link" style="text-decoration: line-through" href="#">Reviews</a>
</li>
<li class="nav-item end-0">
{% if request.user.is_authenticated %}
<a class="nav-link" href="{% url 'auth:logout' %}">Logout</a>
{% else %}
<a class="nav-link" href="{% url 'auth:login' %}">Login</a>
{% endif %}
</li>
</ul>
</div>
</div>
</nav>
{% endblock %}
{% block content %}
<p>Welcome to out little movieclub.</p>
{% endblock %}
<!-- Bootstrap -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
{% endblock %}
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
{% extends 'base.html' %}
{% block content %}
<div class="container-lg">
{% if error %}<div class="alert alert-warning">{{error}}</div>{% endif %}
<form action="{% url 'watchlist:delete' movie.id %}" method="post">
{% csrf_token %}
<input type="submit" value="Delete movie" class="btn btn-danger">
</form>
<form action="{% url 'watchlist:edit' movie.id %}" method="post">
{% csrf_token %}
<div>
<label class="form-label" for="name">Movie name</label>
<input class="form-control" id="name" name="name" value="{{movie.name}}">
</div>
<div>
<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">
{% if request.user.is_staff %}
{% for user in users %}
<option value="{{ user.username }}" {% if user.username == movie.suggested_by.username %} selected {% endif %} >{{user.username}}</option>
{% endfor %}
{% else %}
<option value="{{ movie.suggested_by.username }} disabled">{{movie.suggested_by.username}}</option>
{% endif %}
</select>
</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">
</form>
</div>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block content %}
<div class="container-lg">
<h1>Watchlist</h1>
{% if object_list %}
<ul>
{% for movie in object_list %}
<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="Submit">
</form>
{% endif %}
</div>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends 'base.html' %}
{% block content %}
<div class="container-lg">
<h1 class="display-1">{{ movie.name }}</h1>
<p class="text-secondary">Suggested by: {{movie.suggested_by.username}}</p>
<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>
{% if votes|length == 0 %}
<p>Nobody voted yet, be first...</p>
{% else %}
<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 != "" %} &ndash; {{vote.comment}}{% endif %}</li>
{% endfor %}
</ul>
{% endif %}
{% if request.user.is_authenticated %}
<h2>Your opinion</h2>
<form action="{% url 'watchlist:vote' movie.id %}" method="post">
{% csrf_token %}
<div class="form-check">
<input class="form-check-input" type="radio" name="vote" id="vote-upvote" value="1" {% if user_vote and user_vote.vote == 1 %} checked {% endif %}><label class="form-check-label" for="vote-upvote">Want to watch</label><br>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote" id="vote-novote" value="0" {% if user_vote is None or user_vote.vote == 0 %} checked {% endif %}><label class="form-check-label" for="vote-novote">No opinion</label><br>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="vote" id="vote-downvote" value="-1" {% if user_vote and user_vote.vote == -1 %} checked {% endif %}><label class="form-check-label" for="vote-downvote">Don't want to watch</label><br>
</div>
<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>
</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>
<input class="btn btn-primary" type="submit" value="Submit">
</form>
</h2>
{% endif %}
</div>
{% endblock %}
View File
+24
View File
@@ -0,0 +1,24 @@
from django.contrib import admin
from . import models
class MovieVoteInline(admin.StackedInline):
model = models.MovieVote
extra = 0
class MovieAdmin(admin.ModelAdmin):
fields = [
"name", "watched", "suggested_by", "score"
]
readonly_fields = ("score",)
list_display = ["name", "watched", "suggested_by", "score"]
inlines = [
MovieVoteInline
]
@admin.display(description="Score")
def score(self, instance):
return instance.score
admin.site.register(models.Movie, MovieAdmin)
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class WatchlistConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'watchlist'
+9
View File
@@ -0,0 +1,9 @@
from django import forms
from . import models
class MovieEditForm(forms.ModelForm):
class Meta:
model = models.Movie
fields = ["name", "suggested_by", "watched", "imdb_id", "csfd_id"]
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 4.1.5 on 2023-01-12 21:45
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Movie',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
],
),
migrations.CreateModel(
name='MovieVote',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('vote', models.IntegerField(choices=[(1, 'Upvote'), (0, 'Novote'), (-1, 'Downvote')], default=0)),
('seen', models.BooleanField(default=False, null=True)),
('movie', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='watchlist.movie')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
@@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-01-12 21:57
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('watchlist', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='movie',
name='suggested_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='movie',
name='watched',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-01-15 15:57
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('watchlist', '0002_movie_suggested_by_movie_watched'),
]
operations = [
migrations.AlterModelOptions(
name='movie',
options={'permissions': [('change_suggested_by', 'Can change who suggested movie'), ('change_watched', 'Can mark as watched')]},
),
migrations.AlterField(
model_name='movie',
name='suggested_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.1.5 on 2023-01-15 16:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('watchlist', '0003_alter_movie_options_alter_movie_suggested_by'),
]
operations = [
migrations.AlterModelOptions(
name='movie',
options={'permissions': [('moderate_movies', "Can edit other's movies and mark them as watched")]},
),
]
@@ -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,
),
]
View File
+56
View File
@@ -0,0 +1,56 @@
import re
from functools import reduce
from django.db import models
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>[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 Meta:
permissions = [
("moderate_movies", "Can edit other's movies and mark them as watched"),
]
name = models.CharField(max_length=100)
suggested_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
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
def score(self):
return reduce(lambda result,v: result+v.vote, self.movievote_set.all(), 0)
@property
def seen_score(self):
return reduce(lambda result,v: result+int(v.seen), self.movievote_set.all(), 0)
def __str__(self):
return self.name
class MovieVote(models.Model):
class Vote(models.IntegerChoices):
UPVOTE = 1
NOVOTE = 0
DOWNVOTE = -1
movie = models.ForeignKey(Movie, on_delete=models.CASCADE)
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(blank=True)
def __str__(self):
return f"{self.user.username}'s vote for {self.movie.name}"
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, "")
+12
View File
@@ -0,0 +1,12 @@
from django.urls import path
from . import views
app_name = "watchlist"
urlpatterns = [
path('', views.IndexView.as_view(), name="index"),
path('movie/<int:pk>', views.DetailView.as_view(), name="detail"),
path('movie/<int:pk>/vote', views.vote, name="vote"),
path('movie/<int:pk>/edit', views.EditView.as_view(), name="edit"),
path('movie/<int:pk>/delete', views.delete, name="delete"),
path('movie', views.submit, name="submit")
]
+141
View File
@@ -0,0 +1,141 @@
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
from django.urls import reverse
from django.shortcuts import get_object_or_404, render
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from . import models
class IndexView(generic.ListView):
template_name = "watchlist/index.html"
model = models.Movie
def get_queryset(self):
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):
context = super().get_context_data()
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
class DetailView(generic.DetailView):
model = models.Movie
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
votes = self.object.movievote_set.all()
user_vote = None
if self.request.user.is_authenticated:
user_vote = votes.filter(user=self.request.user).first()
context["votes"] = votes
context["user_vote"] = user_vote
return context
@login_required
@require_POST
def vote(request, pk):
movie = get_object_or_404(models.Movie, pk=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 != "":
user_vote.comment = comment
user_vote.save()
return HttpResponseRedirect(reverse('watchlist:index'))
class EditView(generic.DetailView):
model = models.Movie
template_name = "watchlist/edit.html"
def can_edit_movie(self, request):
return request.user.has_perm('watchlist.moderate_movies') or request.user == self.object.suggested_by
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["error"] = self.request.GET.get("error", None)
return context
def get(self, request, *args, **kwargs):
self.object = self.get_object()
if not self.can_edit_movie(request):
return HttpResponseForbidden("You cannot edit this object.")
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if not self.can_edit_movie(request):
return HttpResponseForbidden("You cannot edit this object.")
if "name" in request.POST:
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 request.user.has_perm('watchlist.moderate_movies'):
new_suggestor = User.objects.filter(username=request.POST["suggested_by"]).first()
if new_suggestor is None:
return HttpResponseBadRequest("The new suggestor doesn't exist.")
self.object.suggested_by = new_suggestor
# else:
# if request.POST["suggested_by"] != self.object.suggested_by.username:
# return HttpResponseForbidden("You cannot change the suggested by field.")
self.object.save()
return HttpResponseRedirect(reverse('watchlist:detail', args=(kwargs["pk"],)))
@login_required
@require_POST
def submit(request):
if not request.user.has_perm("watchlist.add_movie"):
return HttpResponseForbidden("You can't add new movies.")
movie = models.Movie(name=request.POST["name"], suggested_by=request.user, watched=False)
movie.save()
return HttpResponseRedirect(reverse("watchlist:edit", args=(movie.id,)))
@login_required
@require_POST
def delete(request, pk):
movie = get_object_or_404(models.Movie, pk=pk)
if not (request.user.has_perm("watchlist.moderate_movies") or (
request.user.has_perm("watchlist.delete_movie") and request.user == movie.suggested_by
)):
return HttpResponseForbidden("You can't delete this movie")
movie.delete()
return HttpResponseRedirect(reverse("watchlist:index"))