Merge branch 'ui'

This commit is contained in:
ducklet 2021-08-05 19:20:33 +02:00
commit 792806f304
31 changed files with 2934 additions and 111 deletions

View file

@ -1,2 +1,4 @@
*.local *.local
/data /data
/tests
/unwind-ui

2
Procfile Normal file
View file

@ -0,0 +1,2 @@
server: ./run dev-server
ui: ./run dev-ui

67
poetry.lock generated
View file

@ -11,7 +11,7 @@ typing_extensions = ">=3.7.2"
[[package]] [[package]]
name = "asgiref" name = "asgiref"
version = "3.3.4" version = "3.4.1"
description = "ASGI specs, helper code, and adapters" description = "ASGI specs, helper code, and adapters"
category = "main" category = "main"
optional = false optional = false
@ -59,19 +59,22 @@ lxml = ["lxml"]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2020.12.5" version = "2021.5.30"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
category = "main" category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
[[package]] [[package]]
name = "chardet" name = "charset-normalizer"
version = "4.0.0" version = "2.0.4"
description = "Universal encoding detector for Python 2 and 3" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=3.5.0"
[package.extras]
unicode_backport = ["unicodedata2"]
[[package]] [[package]]
name = "click" name = "click"
@ -138,11 +141,11 @@ lxml = ["lxml"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "2.10" version = "3.2"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=3.5"
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
@ -154,11 +157,11 @@ python-versions = "*"
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "20.9" version = "21.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "main" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=3.6"
[package.dependencies] [package.dependencies]
pyparsing = ">=2.0.2" pyparsing = ">=2.0.2"
@ -213,21 +216,21 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm
[[package]] [[package]]
name = "requests" name = "requests"
version = "2.25.1" version = "2.26.0"
description = "Python HTTP for Humans." description = "Python HTTP for Humans."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.dependencies] [package.dependencies]
certifi = ">=2017.4.17" certifi = ">=2017.4.17"
chardet = ">=3.0.2,<5" charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""}
idna = ">=2.5,<3" idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""}
urllib3 = ">=1.21.1,<1.27" urllib3 = ">=1.21.1,<1.27"
[package.extras] [package.extras]
security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"]
socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"]
use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"]
[[package]] [[package]]
name = "six" name = "six"
@ -302,16 +305,16 @@ python-versions = "*"
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "1.26.4" version = "1.26.6"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
category = "main" category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4"
[package.extras] [package.extras]
brotli = ["brotlipy (>=0.6.0)"]
secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
brotli = ["brotlipy (>=0.6.0)"]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
@ -348,8 +351,8 @@ aiosqlite = [
{file = "aiosqlite-0.17.0.tar.gz", hash = "sha256:f0e6acc24bc4864149267ac82fb46dfb3be4455f99fe21df82609cc6e6baee51"}, {file = "aiosqlite-0.17.0.tar.gz", hash = "sha256:f0e6acc24bc4864149267ac82fb46dfb3be4455f99fe21df82609cc6e6baee51"},
] ]
asgiref = [ asgiref = [
{file = "asgiref-3.3.4-py3-none-any.whl", hash = "sha256:92906c611ce6c967347bbfea733f13d6313901d54dcca88195eaeb52b2a8e8ee"}, {file = "asgiref-3.4.1-py3-none-any.whl", hash = "sha256:ffc141aa908e6f175673e7b1b3b7af4fdb0ecb738fc5c8b88f69f055c2415214"},
{file = "asgiref-3.3.4.tar.gz", hash = "sha256:d1216dfbdfb63826470995d31caed36225dcaf34f182e0fa257a4dd9e86f1b78"}, {file = "asgiref-3.4.1.tar.gz", hash = "sha256:4ef1ab46b484e3c706329cedeff284a5d40824200638503f5768edb6de7d58e9"},
] ]
atomicwrites = [ atomicwrites = [
{file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"},
@ -365,12 +368,12 @@ beautifulsoup4 = [
{file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"},
] ]
certifi = [ certifi = [
{file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
{file = "certifi-2020.12.5.tar.gz", hash = "sha256:1a4995114262bffbc2413b159f2a1a480c969de6e6eb13ee966d470af86af59c"}, {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
] ]
chardet = [ charset-normalizer = [
{file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"},
{file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"},
] ]
click = [ click = [
{file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"},
@ -393,16 +396,16 @@ html5lib = [
{file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"}, {file = "html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f"},
] ]
idna = [ idna = [
{file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"},
{file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"},
] ]
iniconfig = [ iniconfig = [
{file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"},
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
] ]
packaging = [ packaging = [
{file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"},
{file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"},
] ]
pluggy = [ pluggy = [
{file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"},
@ -421,8 +424,8 @@ pytest = [
{file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"},
] ]
requests = [ requests = [
{file = "requests-2.25.1-py2.py3-none-any.whl", hash = "sha256:c210084e36a42ae6b9219e00e48287def368a26d03a048ddad7bfee44f75871e"}, {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"},
{file = "requests-2.25.1.tar.gz", hash = "sha256:27973dd4a904a4f13b263a19c866c13b92a39ed1c964655f025f3f8d3d75b804"}, {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"},
] ]
six = [ six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
@ -486,8 +489,8 @@ ulid-py = [
{file = "ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987"}, {file = "ulid_py-1.1.0-py2.py3-none-any.whl", hash = "sha256:b56a0f809ef90d6020b21b89a87a48edc7c03aea80e5ed5174172e82d76e3987"},
] ]
urllib3 = [ urllib3 = [
{file = "urllib3-1.26.4-py2.py3-none-any.whl", hash = "sha256:2f4da4594db7e1e110a944bb1b551fdf4e6c136ad42e4234131391e21eb5b0df"}, {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"},
{file = "urllib3-1.26.4.tar.gz", hash = "sha256:e7b021f7241115872f92f43c6508082facffbd1c048e3c6e2bb9c2a157e28937"}, {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"},
] ]
uvicorn = [ uvicorn = [
{file = "uvicorn-0.14.0-py3-none-any.whl", hash = "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae"}, {file = "uvicorn-0.14.0-py3-none-any.whl", hash = "sha256:2a76bb359171a504b3d1c853409af3adbfa5cef374a4a59e5881945a97a93eae"},

View file

@ -4,4 +4,7 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
exec poetry export -o requirements.txt poetry export -o requirements.txt
cd unwind-ui
npm run build

View file

@ -4,4 +4,4 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
exec uvicorn unwind:create_app --factory --reload exec honcho start

7
scripts/dev-server Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh -eu
cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x
exec uvicorn unwind:create_app --factory --reload

7
scripts/dev-ui Executable file
View file

@ -0,0 +1,7 @@
#!/bin/sh -eu
cd "$RUN_DIR"/unwind-ui
[ -z "${DEBUG:-}" ] || set -x
exec npm run dev

View file

@ -1,8 +1,6 @@
#!/bin/sh -eu #!/bin/sh -eu
cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
isort --profile black unwind "$RUN_BIN" lint-js "$@"
black unwind "$RUN_BIN" lint-py "$@"

21
scripts/lint-js Executable file
View file

@ -0,0 +1,21 @@
#!/bin/sh -eu
cd "$RUN_DIR"
if [ "${1:-}" = '--fix' ]; then
mode=fix
else
mode=check
fi
if [ "$mode" = 'fix' ]; then
prettier_opts=--write
else
prettier_opts=--check
fi
[ -z "${DEBUG:-}" ] || set -x
cd unwind-ui
npm run lint ||:
npx prettier $prettier_opts 'vite.config.ts' 'src/**/*.{js,ts,vue}'

8
scripts/lint-py Executable file
View file

@ -0,0 +1,8 @@
#!/bin/sh -eu
cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x
isort --profile black unwind
black unwind

2
unwind-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules
dist

12
unwind-ui/index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>🧶 Unwind</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1698
unwind-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
unwind-ui/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "vue-tsc --noEmit",
"serve": "vite preview"
},
"dependencies": {
"bulma": "^0.9.3",
"vue": "^3.0.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^1.2.5",
"@vue/compiler-sfc": "^3.0.5",
"typescript": "^4.3.2",
"vite": "^2.4.2",
"vue-tsc": "^0.0.24"
},
"prettier": {
"arrowParens": "always",
"printWidth": 88,
"trailingComma": "all",
"semi": false
}
}

259
unwind-ui/src/App.vue Normal file
View file

@ -0,0 +1,259 @@
<template>
<section class="section">
<div class="container">
<user-login
class="is-justify-content-end"
@login="(login) => (credentials = login)"
/>
<div class="field is-flex is-justify-content-end" v-if="login_user">
<div class="control has-icons-left">
<div class="select is-small">
<select v-model="filter_group" @change="search">
<option selected value="">Me ({{ login_user.name }})</option>
<option v-for="g in login_user.groups" :key="g.id" :value="g.id">
{{ g.id }}
</option>
</select>
</div>
<div class="icon is-small is-left">
<span class="fas fa-group">👥</span>
</div>
</div>
</div>
<div class="field has-addons mt-6">
<div class="control is-expanded">
<input
class="input is-loading"
type="text"
placeholder="Movie title"
v-model="query"
@change="search"
/>
</div>
<p class="control">
<span class="select">
<select v-model="media_type" @change="search">
<option value="">(any type)</option>
<option v-for="t in media_types" :key="t" :value="t">{{ t }}</option>
</select>
</span>
</p>
<div class="control">
<a class="button is-info" :disabled="!active"> Search </a>
</div>
</div>
<movie-list :items="items" @reachBottom="loadMore" />
<footer class="footer" v-if="is_end">
<p class="subtitle content has-text-centered is-italic"> fin </p>
</footer>
</div>
</section>
</template>
<script lang="ts">
import { defineComponent } from "vue"
import config from "./config.ts"
import { is_object } from "./utils.ts"
const media_types = [
"Movie",
"Radio Episode",
"Radio Series",
"Short",
"TV Episode",
"TV Mini Series",
"TV Movie",
"TV Series",
"TV Short",
"TV Special",
"Video",
"Video Game",
]
function clean_obj(o) {
return Object.fromEntries(Object.entries(o).filter(([k, v]) => v))
}
function api_url(resource, params = []) {
const url = new URL(resource, config.api_url)
if (is_object(params)) {
params = clean_obj(params)
}
if (is_object(params) || params.length) {
url.search = new URLSearchParams(params)
}
return url
}
function set_view_params(params) {
history.replaceState(null, "", "#" + new URLSearchParams(clean_obj(params)))
}
function view_params() {
const fragment = (window.location.hash || "").replace(/^#/, "")
const params = new URLSearchParams(fragment)
return {
query: params.get("query") || "",
type: params.get("type") || "",
}
}
async function req(url, opts = {}, { user_id = "", secret = "" }, data = null) {
opts.headers = opts.headers || {}
if (data !== null) {
opts.body = JSON.stringify(data)
opts.headers["Content-Type"] = "application/json"
}
if (secret) {
const credentials = btoa(`${user_id}:${secret}`)
opts.headers["Authorization"] = `Basic ${credentials}`
opts.mode = "cors"
opts.credentials = "include"
}
const resp = await window.fetch(url, opts)
if (!resp.ok) {
throw resp
}
return await resp.json()
}
async function get(resource, params = [], user = {}) {
const url = api_url(resource, params)
const opts = {}
return await req(url, opts, user)
}
async function patch(resource, params = [], user = {}, data = null) {
const url = api_url(resource, params)
const opts = { method: "PATCH" }
return await req(url, opts, user, data)
}
function debounce_async(ms, func) {
let abort
return async (...args) => {
if (abort) {
abort()
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
resolve(func(...args))
}, ms)
abort = () => {
reject("Function call aborted, replaced by a later call.")
clearTimeout(timer)
abort = null
}
})
}
}
const debounced_get = debounce_async(100, get)
type ULID = string
type User = {
id: ULID
name: string
groups: Array<{ id: ULID }>
}
export default defineComponent({
data: () => ({
query: "",
items: [],
credentials: { user_id: "", secret: "" },
login_user: null,
page: 1,
is_end: false,
media_types,
media_type: "",
active: false,
filter_group: "",
}),
mounted() {
const { query, type } = view_params()
this.query = query
this.media_type = media_types.includes(type) ? type : ""
},
methods: {
async search() {
this.active = true
set_view_params({ query: this.query, type: this.media_type })
if (!this.query) {
return
}
this.page = 1
this.is_end = false
this.items = []
await this.loadMore()
},
async loadMore() {
this.active = false
if (!this.query || this.is_end) {
return
}
const per_page = 100
const media_type = this.media_type === "" ? null : this.media_type
const user_id = (this.filter_group === "" && this.credentials.user_id) || null
const group_id = user_id === null && this.filter_group ? this.filter_group : null
const more = await debounced_get(
"movies",
{
title: this.query,
per_page,
include_unrated: true,
page: this.page,
media_type,
user_id,
group_id,
},
this.credentials,
)
this.items = this.items.concat(more)
if (more.length < per_page) {
this.is_end = true
} else {
this.page += 1
}
},
},
watch: {
async credentials(creds) {
if (!creds.user_id) {
return
}
const user: User = await get(`users/${creds.user_id}`, {}, creds)
this.login_user = user
},
},
})
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
margin-top: 60px;
}
</style>

View file

@ -0,0 +1,138 @@
<template>
<table class="table is-fullwidth is-striped">
<thead>
<tr>
<th>title</th>
<th>year</th>
<th>type</th>
<th>rating</th>
<th>runtime</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id" :data-unwind-id="item.id">
<td v-if="item.original_title">
<a :href="`https://www.imdb.com/title/${item.imdb_id}/`">
<span>{{ item.original_title }}</span>
</a>
<span v-if="item.title != item.original_title"> ({{ item.title }})</span>
</td>
<td v-else>
<a :href="`https://www.imdb.com/title/${item.imdb_id}/`">
<span>{{ item.title }}</span>
</a>
</td>
<td>
<span class="year">{{ item.release_year }}</span>
</td>
<td>
<span class="mediatype">{{ item.media_type }}</span>
</td>
<td>
<span
class="score imdb-score tag is-large"
:title="`IMDb rating (1-10) / ${item.imdb_votes} votes`"
>{{ imdb_rating(item.imdb_score) }}</span
>
<span
class="score tag is-info is-large"
:title="`User rating (1-10) / ${
item.user_scores.length
} votes / σ = ${imdb_stdev(item.user_scores)}`"
>{{ avg_imdb_rating(item.user_scores) }}</span
>
</td>
<td>
<span>{{ duration(item.runtime) }}</span>
</td>
</tr>
</tbody>
</table>
<div ref="sentinel"></div>
</template>
<script lang="ts">
import { defineComponent } from "vue"
import { mean, pstdev } from "../utils.ts"
function avg_imdb_rating(scores) {
return imdb_rating(scores.length === 0 ? null : mean(scores))
}
function imdb_stdev(scores) {
return pstdev(scores.map(imdb_rating_from_score))
}
function imdb_rating_from_score(score) {
return Math.round((score * 9) / 10 + 10) / 10
}
function imdb_rating(score) {
if (score == null) {
return "-"
}
const deci = 10 * imdb_rating_from_score(score)
return `${(deci / 10) | 0}.${deci % 10}`
}
function duration(minutes_total) {
if (minutes_total == null) {
return "-"
}
const m = minutes_total % 60
const h = (minutes_total / 60) | 0
return `${h} h ${m} m`
}
export default defineComponent({
props: {
items: {
type: Array,
required: true,
},
},
emits: ["reach-bottom"],
mounted() {
const options = {
rootMargin: "500px",
}
const observer = new IntersectionObserver(([e]) => {
if (!e.isIntersecting) {
return
}
this.$emit("reach-bottom")
}, options)
observer.observe(this.$refs.sentinel)
},
methods: {
avg_imdb_rating,
imdb_rating,
imdb_stdev,
duration,
},
})
</script>
<style scoped>
.year {
color: grey;
}
.mediatype {
color: grey;
}
.score {
width: 2em;
height: 2em;
}
.score + .score {
margin-left: 1em;
}
.imdb-score {
background-color: rgb(245, 197, 24);
}
.user-score {
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<div class="field is-grouped">
<p class="control has-icons-left">
<input
class="input is-small"
type="text"
placeholder="User ID"
v-model="user_id"
@change="active = true"
/>
<span class="icon is-small is-left">
<span class="fas fa-user">👤</span>
</span>
</p>
<p class="control has-icons-left">
<input
class="input is-small"
type="password"
placeholder="Secret"
v-model="secret"
@change="active = true"
/>
<span class="icon is-small is-left">
<span class="fas fa-lock">🔓</span>
</span>
</p>
<p class="control">
<button class="button is-primary is-small" :disabled="!active" @click="login">
Login
</button>
</p>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue"
export default defineComponent({
emits: ["login"],
data: () => ({
user_id: window.localStorage.user_id || "",
secret: window.localStorage.secret || "",
active: true,
}),
mounted() {
const { user_id, secret } = this
if (user_id && secret) {
this.login()
}
},
methods: {
login() {
const { user_id, secret } = this
this.$emit("login", { user_id, secret })
this.active = false
},
},
watch: {
user_id(newval) {
window.localStorage.user_id = newval
},
secret(newval) {
window.localStorage.secret = newval
},
},
})
</script>
<style scoped></style>

3
unwind-ui/src/config.ts Normal file
View file

@ -0,0 +1,3 @@
export default {
api_url: `${process.env.API_URL}v1/`,
}

12
unwind-ui/src/main.ts Normal file
View file

@ -0,0 +1,12 @@
import { createApp } from "vue"
import App from "./App.vue"
import MovieList from "./components/MovieList.vue"
import UserLogin from "./components/UserLogin.vue"
import "bulma/css/bulma.css"
const app = createApp(App)
app.component("movie-list", MovieList)
app.component("user-login", UserLogin)
app.mount("#app")

6
unwind-ui/src/shims-vue.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
declare module "*.vue" {
import { DefineComponent } from "vue"
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}

18
unwind-ui/src/utils.ts Normal file
View file

@ -0,0 +1,18 @@
export function is_object(x) {
return x !== null && typeof x === "object" && !Array.isArray(x)
}
export function sum(nums) {
return nums.reduce((s, n) => s + n, 0)
}
export function mean(nums) {
return sum(nums) / nums.length
}
export function pstdev(nums, mu = null) {
if (mu === null) {
mu = mean(nums)
}
return Math.sqrt(mean(nums.map((n) => (n - mu) ** 2)))
}

1
unwind-ui/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

14
unwind-ui/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": false,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["esnext", "dom"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

13
unwind-ui/vite.config.ts Normal file
View file

@ -0,0 +1,13 @@
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
// https://vitejs.dev/config/
export default defineConfig({
base: process.env.BASE_URL || "/",
define: {
"process.env.API_URL": JSON.stringify(
process.env.API_URL || "http://localhost:8000/api/",
),
},
plugins: [vue()],
})

View file

@ -16,4 +16,6 @@ config_path = os.getenv("UNWIND_CONFIG", datadir / "config.toml")
_config = toml.load(config_path) _config = toml.load(config_path)
api_credentials = _config["api"]["credentials"] api_base = _config["api"].get("base", "/api/")
api_cors = _config["api"].get("cors", "*")
api_credentials = _config["api"].get("credentials", {})

View file

@ -1,4 +1,3 @@
import json
import logging import logging
import re import re
from pathlib import Path from pathlib import Path
@ -19,8 +18,10 @@ from .models import (
optional_fields, optional_fields,
utcnow, utcnow,
) )
from .types import ULID
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
T = TypeVar("T")
_shared_connection: Optional[Database] = None _shared_connection: Optional[Database] = None
@ -256,6 +257,12 @@ async def update(item):
await shared_connection().execute(query=query, values=values) await shared_connection().execute(query=query, values=values)
async def remove(item):
values = asplain(item, fields_={"id"})
query = f"DELETE FROM {item._table} WHERE id=:id"
await shared_connection().execute(query=query, values=values)
async def add_or_update_user(user: User): async def add_or_update_user(user: User):
db_user = await get(User, imdb_id=user.imdb_id) db_user = await get(User, imdb_id=user.imdb_id)
if not db_user: if not db_user:
@ -453,6 +460,135 @@ async def find_ratings(
return tuple(dict(r) for r in rows) return tuple(dict(r) for r in rows)
def sql_fields(tp: Type):
return (f"{tp._table}.{f.name}" for f in fields(tp))
def sql_fieldmap(tp: Type):
"""-> {alias: (table, field_name)}"""
return {f"{tp._table}_{f.name}": (tp._table, f.name) for f in fields(tp)}
def mux(*tps: Type):
return ", ".join(
f"{t}.{n} AS {k}" for tp in tps for k, (t, n) in sql_fieldmap(tp).items()
)
def demux(tp: Type[ModelType], row) -> ModelType:
return fromplain(tp, {n: row[k] for k, (_, n) in sql_fieldmap(tp).items()})
def sql_in(column: str, values: list[T]) -> tuple[str, dict[str, T]]:
c = column.replace(".", "___")
value_map = {f"{c}_{i}": v for i, v in enumerate(values, start=1)}
placeholders = ",".join(":" + k for k in value_map)
return f"{column} IN ({placeholders})", value_map
async def ratings_for_movies(
movie_ids: Iterable[ULID], user_ids: Iterable[ULID] = []
) -> Iterable[Rating]:
values: dict[str, str] = {}
conditions: list[str] = []
q, vm = sql_in("movie_id", [str(m) for m in movie_ids])
conditions.append(q)
values.update(vm)
if user_ids:
q, vm = sql_in("user_id", [str(m) for m in user_ids])
conditions.append(q)
values.update(vm)
query = f"""
SELECT {','.join(sql_fields(Rating))}
FROM {Rating._table}
WHERE {' AND '.join(f'({c})' for c in conditions) if conditions else '1=1'}
"""
rows = await shared_connection().fetch_all(query, values)
return (fromplain(Rating, row) for row in rows)
async def find_movies(
*,
title: str = None,
media_type: str = None,
exact: bool = False,
ignore_tv_episodes: bool = False,
yearcomp: tuple[Literal["<", "=", ">"], int] = None,
limit_rows: int = 10,
skip_rows: int = 0,
include_unrated: bool = False,
user_ids: list[ULID] = [],
) -> Iterable[tuple[Movie, list[Rating]]]:
values: dict[str, Union[int, str]] = {
"limit_rows": limit_rows,
"skip_rows": skip_rows,
}
conditions = []
if title:
values["escape"] = "#"
escaped_title = sql_escape(title, char=values["escape"])
values["pattern"] = (
"_".join(escaped_title.split())
if exact
else "%" + "%".join(escaped_title.split()) + "%"
)
conditions.append(
f"""
(
{Movie._table}.title LIKE :pattern ESCAPE :escape
OR {Movie._table}.original_title LIKE :pattern ESCAPE :escape
)
"""
)
if yearcomp:
op, year = yearcomp
assert op in "<=>"
values["year"] = year
conditions.append(f"{Movie._table}.release_year{op}:year")
if media_type:
values["media_type"] = media_type
conditions.append(f"{Movie._table}.media_type=:media_type")
if ignore_tv_episodes:
conditions.append(f"{Movie._table}.media_type!='TV Episode'")
if not include_unrated:
conditions.append(f"{Movie._table}.score NOTNULL")
query = f"""
SELECT {','.join(sql_fields(Movie))}
FROM {Movie._table}
WHERE {(' AND '.join(conditions)) if conditions else '1=1'}
ORDER BY
length({Movie._table}.title) ASC,
{Movie._table}.imdb_score DESC,
{Movie._table}.release_year DESC
LIMIT :skip_rows, :limit_rows
"""
rows = await shared_connection().fetch_all(bindparams(query, values))
movies = [fromplain(Movie, row) for row in rows]
if not user_ids:
return ((m, []) for m in movies)
ratings = await ratings_for_movies((m.id for m in movies), user_ids)
aggreg: dict[ULID, tuple[Movie, list[Rating]]] = {m.id: (m, []) for m in movies}
for rating in ratings:
aggreg[rating.movie_id][1].append(rating)
return aggreg.values()
def bindparams(query: str, values: dict): def bindparams(query: str, values: dict):
"""Bind values to a query. """Bind values to a query.

View file

@ -4,7 +4,7 @@ import logging
from dataclasses import dataclass, fields from dataclasses import dataclass, fields
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Optional, cast from typing import Generator, Literal, Optional, Type, TypeVar, overload
from . import config, db, request from . import config, db, request
from .db import add_or_update_many_movies from .db import add_or_update_many_movies
@ -13,6 +13,7 @@ from .models import Movie
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
T = TypeVar("T")
# See # See
# - https://www.imdb.com/interfaces/ # - https://www.imdb.com/interfaces/
@ -120,6 +121,20 @@ def count_lines(path) -> int:
return i return i
@overload
def read_imdb_tsv(
path, row_type, *, unpack: Literal[False]
) -> Generator[list[str], None, None]:
...
@overload
def read_imdb_tsv(
path, row_type: Type[T], *, unpack: Literal[True] = True
) -> Generator[T, None, None]:
...
def read_imdb_tsv(path, row_type, *, unpack=True): def read_imdb_tsv(path, row_type, *, unpack=True):
with gzip.open(path, "rt", newline="") as f: with gzip.open(path, "rt", newline="") as f:
rows = csv.reader(f, delimiter="\t", quoting=csv.QUOTE_NONE) rows = csv.reader(f, delimiter="\t", quoting=csv.QUOTE_NONE)
@ -158,7 +173,6 @@ def read_ratings(path):
def read_ratings_as_mapping(path): def read_ratings_as_mapping(path):
"""Optimized function to quickly load all ratings.""" """Optimized function to quickly load all ratings."""
rows = read_imdb_tsv(path, RatingRow, unpack=False) rows = read_imdb_tsv(path, RatingRow, unpack=False)
rows = cast(list[list[str]], rows)
return {r[0]: (round(100 * (float(r[1]) - 1) / 9), int(r[2])) for r in rows} return {r[0]: (round(100 * (float(r[1]) - 1) / 9), int(r[2])) for r in rows}

View file

@ -6,6 +6,7 @@ from typing import (
Annotated, Annotated,
Any, Any,
ClassVar, ClassVar,
Literal,
Optional, Optional,
Type, Type,
TypeVar, TypeVar,
@ -16,6 +17,8 @@ from typing import (
from .types import ULID from .types import ULID
T = TypeVar("T")
def annotations(tp: Type) -> Optional[tuple]: def annotations(tp: Type) -> Optional[tuple]:
return tp.__metadata__ if hasattr(tp, "__metadata__") else None return tp.__metadata__ if hasattr(tp, "__metadata__") else None
@ -69,12 +72,15 @@ def optional_fields(o):
yield f yield f
def asplain(o) -> dict[str, Any]: def asplain(o, *, fields_: set = None) -> dict[str, Any]:
validate(o) validate(o)
d = {} d = {}
for f in fields(o): for f in fields(o):
if fields_ is not None and f.name not in fields_:
continue
target = f.type target = f.type
# XXX this doesn't properly support any kind of nested types # XXX this doesn't properly support any kind of nested types
if (otype := optional_type(f.type)) is not None: if (otype := optional_type(f.type)) is not None:
@ -99,7 +105,7 @@ def asplain(o) -> dict[str, Any]:
return d return d
def fromplain(cls, d: dict[str, Any]): def fromplain(cls: Type[T], d: dict[str, Any]) -> T:
dd = {} dd = {}
for f in fields(cls): for f in fields(cls):
@ -229,7 +235,6 @@ class Movie:
self._is_lazy = False self._is_lazy = False
T = TypeVar("T")
_RelationSentinel = object() _RelationSentinel = object()
"""Mark a model field as containing external data. """Mark a model field as containing external data.
@ -271,6 +276,13 @@ class Rating:
) )
Access = Literal[
"r", # read
"i", # index
"w", # write
]
@dataclass @dataclass
class User: class User:
_table: ClassVar[str] = "users" _table: ClassVar[str] = "users"
@ -278,6 +290,21 @@ class User:
id: ULID = field(default_factory=ULID) id: ULID = field(default_factory=ULID)
imdb_id: str = None imdb_id: str = None
name: str = None # canonical user name name: str = None # canonical user name
secret: str = None
groups: list[dict[str, str]] = field(default_factory=list)
def has_access(self, group_id: Union[ULID, str], access: Access = "r"):
group_id = group_id if isinstance(group_id, str) else str(group_id)
return any(g["id"] == group_id and access == g["access"] for g in self.groups)
def set_access(self, group_id: Union[ULID, str], access: Access):
group_id = group_id if isinstance(group_id, str) else str(group_id)
for g in self.groups:
if g["id"] == group_id:
g["access"] = access
break
else:
self.groups.append({"id": group_id, "access": access})
@dataclass @dataclass
@ -286,5 +313,4 @@ class Group:
id: ULID = field(default_factory=ULID) id: ULID = field(default_factory=ULID)
name: str = None name: str = None
secret: str = None
users: list[dict[str, str]] = field(default_factory=list) users: list[dict[str, str]] = field(default_factory=list)

View file

@ -0,0 +1,22 @@
-- add secret to users
CREATE TABLE _migrate_users (
id TEXT PRIMARY KEY NOT NULL,
imdb_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
secret TEXT NOT NULL
);;
INSERT INTO _migrate_users
SELECT
id,
imdb_id,
name,
'' AS secret
FROM users
WHERE true;;
DROP TABLE users;;
ALTER TABLE _migrate_users
RENAME TO users;;

View file

@ -0,0 +1,45 @@
-- add group admins
--- remove secrets from groups
CREATE TABLE _migrate_groups (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
users TEXT NOT NULL -- JSON array
);;
INSERT INTO _migrate_groups
SELECT
id,
name,
users
FROM groups
WHERE true;;
DROP TABLE groups;;
ALTER TABLE _migrate_groups
RENAME TO groups;;
--- add group access to users
CREATE TABLE _migrate_users (
id TEXT PRIMARY KEY NOT NULL,
imdb_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
secret TEXT NOT NULL,
groups TEXT NOT NULL -- JSON array
);;
INSERT INTO _migrate_users
SELECT
id,
imdb_id,
name,
secret,
'[]' AS groups
FROM users
WHERE true;;
DROP TABLE users;;
ALTER TABLE _migrate_users
RENAME TO users;;

View file

@ -2,7 +2,7 @@ import asyncio
import logging import logging
import secrets import secrets
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from typing import Literal, Optional from typing import Literal, Optional, overload
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.authentication import ( from starlette.authentication import (
@ -17,22 +17,29 @@ from starlette.background import BackgroundTask
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
from . import config, db, imdb, imdb_import from . import config, db, imdb, imdb_import
from .db import close_connection_pool, find_ratings, open_connection_pool from .db import close_connection_pool, find_movies, find_ratings, open_connection_pool
from .middleware.responsetime import ResponseTimeMiddleware from .middleware.responsetime import ResponseTimeMiddleware
from .models import Group, Movie, User, asplain from .models import Group, Movie, User, asplain
from .types import ULID from .types import ULID
from .utils import b64encode, phc_compare, phc_scrypt from .utils import b64decode, b64encode, phc_compare, phc_scrypt
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# XXX we probably don't need a group secret anymore, if group access is managed
# on a user level; a group secret would be a separate user with full group
# access
class BearingUser(BaseUser):
def __init__(self, token): class AuthedUser(BaseUser):
self.token = token def __init__(self, user_id: str, secret: str):
self.user_id = user_id
self.secret = secret
class BearerAuthBackend(AuthenticationBackend): class BearerAuthBackend(AuthenticationBackend):
@ -43,25 +50,33 @@ class BearerAuthBackend(AuthenticationBackend):
if "Authorization" not in request.headers: if "Authorization" not in request.headers:
return return
# XXX should we remove the auth header after reading, for security reasons?
auth = request.headers["Authorization"] auth = request.headers["Authorization"]
try: try:
scheme, token = auth.split() scheme, credentials = auth.split()
except ValueError: except ValueError:
raise AuthenticationError("Invalid auth credentials") raise AuthenticationError("Invalid auth credentials")
if scheme.lower() != "bearer":
return
roles = [] roles = []
is_admin = token in self.admin_tokens if scheme.lower() == "bearer":
is_admin = credentials in self.admin_tokens
if is_admin: if not is_admin:
user = SimpleUser(self.admin_tokens[token]) return
name = self.admin_tokens[credentials]
user = SimpleUser(name)
roles.append("admin") roles.append("admin")
elif scheme.lower() == "basic":
try:
name, secret = b64decode(credentials).decode().split(":")
except:
raise AuthenticationError("Invalid auth credentials")
user = AuthedUser(name, secret)
else: else:
user = BearingUser(token) return
return AuthCredentials(["authenticated", *roles]), user return AuthCredentials(["authenticated", *roles]), user
@ -101,11 +116,63 @@ def as_int(x, *, max: int = None, min: Optional[int] = 1, default: int = None):
def as_ulid(s: str) -> ULID: def as_ulid(s: str) -> ULID:
try: try:
if not s:
raise ValueError("Invalid ULID.")
return ULID(s) return ULID(s)
except ValueError: except ValueError:
raise HTTPException(422, "Not a valid ULID.") raise HTTPException(422, "Not a valid ULID.")
@overload
async def json_from_body(request) -> dict:
...
@overload
async def json_from_body(request, keys: list[str]) -> list:
...
async def json_from_body(request, keys: list[str] = None):
if not await request.body():
data = {}
else:
try:
data = await request.json()
except JSONDecodeError:
raise HTTPException(422, "Invalid JSON content.")
if not keys:
return data
try:
return [data[k] for k in keys]
except KeyError as err:
raise HTTPException(422, f"Missing data for key: {err.args[0]}")
def is_admin(request):
return "admin" in request.auth.scopes
async def auth_user(request) -> Optional[User]:
if not isinstance(request.user, AuthedUser):
return
user = await db.get(User, id=request.user.user_id)
if not user:
return
is_authed = phc_compare(secret=request.user.secret, phc_string=user.secret)
if not is_authed:
return
return user
_routes = [] _routes = []
@ -123,6 +190,7 @@ route.registered = _routes
@route("/groups/{group_id}/ratings") @route("/groups/{group_id}/ratings")
async def get_ratings_for_group(request): async def get_ratings_for_group(request):
group_id = as_ulid(request.path_params["group_id"]) group_id = as_ulid(request.path_params["group_id"])
group = await db.get(Group, id=str(group_id)) group = await db.get(Group, id=str(group_id))
@ -178,24 +246,77 @@ def not_found(reason: str = "Not Found"):
return JSONResponse({"error": reason}, status_code=404) return JSONResponse({"error": reason}, status_code=404)
def not_implemented():
raise HTTPException(404, "Not yet implemented.")
@route("/movies") @route("/movies")
@requires(["private"]) @requires(["authenticated"])
async def get_movies(request): async def list_movies(request):
imdb_id = request.query_params.get("imdb_id")
movie = await db.get(Movie, imdb_id=imdb_id) params = request.query_params
user = await auth_user(request)
user_ids = set()
if group_id := params.get("group_id"):
group_id = as_ulid(group_id)
group = await db.get(Group, id=str(group_id))
if not group:
return not_found("Group not found.")
is_allowed = is_admin(request) or user and user.has_access(group_id)
if not is_allowed:
return forbidden("No access to group.")
user_ids |= {ULID(u["id"]) for u in group.users}
if user_id := params.get("user_id"):
user_id = as_ulid(user_id)
# Currently a user may only directly access their own ratings.
is_allowed = is_admin(request) or user and user.id == user_id
if not is_allowed:
return forbidden("No access to user.")
user_ids |= {user_id}
if imdb_id := params.get("imdb_id"):
# XXX missing support for user_ids and user_scores
movies = [await db.get(Movie, imdb_id=imdb_id)]
resp = [asplain(m) for m in movies]
else:
per_page = as_int(params.get("per_page"), max=1000, default=5)
page = as_int(params.get("page"), min=1, default=1)
movieratings = await find_movies(
title=params.get("title"),
media_type=params.get("media_type"),
exact=truthy(params.get("exact")),
ignore_tv_episodes=truthy(params.get("ignore_tv_episodes")),
include_unrated=truthy(params.get("include_unrated")),
yearcomp=yearcomp(params["year"]) if "year" in params else None,
limit_rows=per_page,
skip_rows=(page - 1) * per_page,
user_ids=list(user_ids),
)
resp = []
for movie, ratings in movieratings:
mov = asplain(movie)
mov["user_scores"] = [rating.score for rating in ratings]
resp.append(mov)
resp = [asplain(movie)] if movie else []
return JSONResponse(resp) return JSONResponse(resp)
@route("/movies", methods=["POST"]) @route("/movies", methods=["POST"])
@requires(["authenticated", "admin"]) @requires(["authenticated", "admin"])
async def add_movie(request): async def add_movie(request):
pass
not_implemented()
import_lock = asyncio.Lock()
@route("/movies/_reload_imdb", methods=["GET"]) @route("/movies/_reload_imdb", methods=["GET"])
@ -212,6 +333,8 @@ async def progress_for_load_imdb_movies(request):
status = None status = None
if error: if error:
status = "Error during import." status = "Error during import."
elif percent == 0.0 and progress.stopped:
status = "Import skipped."
elif percent < 100: elif percent < 100:
status = "Import is running." status = "Import is running."
else: else:
@ -228,10 +351,14 @@ async def progress_for_load_imdb_movies(request):
return JSONResponse(resp) return JSONResponse(resp)
_import_lock = asyncio.Lock()
@route("/movies/_reload_imdb", methods=["POST"]) @route("/movies/_reload_imdb", methods=["POST"])
@requires(["authenticated", "admin"]) @requires(["authenticated", "admin"])
async def load_imdb_movies(request): async def load_imdb_movies(request):
async with import_lock:
async with _import_lock:
progress = await db.get_import_progress() progress = await db.get_import_progress()
if progress and not progress.stopped: if progress and not progress.stopped:
return JSONResponse( return JSONResponse(
@ -250,26 +377,166 @@ async def load_imdb_movies(request):
@route("/users") @route("/users")
@requires(["authenticated", "admin"]) @requires(["authenticated", "admin"])
async def list_users(request): async def list_users(request):
users = await db.get_all(User) users = await db.get_all(User)
return JSONResponse([asplain(u) for u in users]) return JSONResponse([asplain(u) for u in users])
@route("/users", methods=["POST"]) @route("/users", methods=["POST"])
@requires(["authenticated", "admin"]) @requires(["authenticated", "admin"])
async def add_user(request): async def add_user(request):
pass
name, imdb_id = await json_from_body(request, ["name", "imdb_id"])
# XXX restrict name
# XXX check if imdb_id is well-formed
secret = secrets.token_bytes()
user = User(name=name, imdb_id=imdb_id, secret=phc_scrypt(secret))
await db.add(user)
return JSONResponse(
{
"secret": b64encode(secret),
"user": asplain(user),
}
)
@route("/users/{user_id}")
@requires(["authenticated"])
async def show_user(request):
user_id = as_ulid(request.path_params["user_id"])
if is_admin(request):
user = await db.get(User, id=str(user_id))
else:
user = await auth_user(request)
if not user:
return not_found()
is_allowed = user.id == user_id
if not is_allowed:
return forbidden()
# Redact `secret`
resp = asplain(user)
resp["secret"] = None
# Fix `groups`
resp["groups"] = user.groups
return JSONResponse(resp)
@route("/users/{user_id}", methods=["DELETE"])
@requires(["authenticated", "admin"])
async def remove_user(request):
user_id = as_ulid(request.path_params["user_id"])
user = await db.get(User, id=str(user_id))
if not user:
return not_found()
async with db.shared_connection().transaction():
# XXX remove user refs from groups and ratings
await db.remove(user)
return JSONResponse(asplain(user))
@route("/users/{user_id}", methods=["PATCH"])
@requires(["authenticated"])
async def modify_user(request):
user_id = as_ulid(request.path_params["user_id"])
if is_admin(request):
user = await db.get(User, id=str(user_id))
else:
user = await auth_user(request)
if not user:
return not_found()
is_allowed = user.id == user_id
if not is_allowed:
return forbidden()
data = await json_from_body(request)
if "name" in data:
if not is_admin(request):
return forbidden("Changing user name is not allowed.")
# XXX restrict name
user.name = data["name"]
if "imdb_id" in data:
if not is_admin(request):
return forbidden("Changing IMDb ID is not allowed.")
# XXX check if imdb_id is well-formed
user.imdb_id = data["imdb_id"]
if "secret" in data:
try:
secret = b64decode(data["secret"])
except:
raise HTTPException(422, f"Invalid secret.")
user.secret = phc_scrypt(secret)
await db.update(user)
return JSONResponse(asplain(user))
@route("/users/{user_id}/groups", methods=["POST"])
@requires(["authenticated", "admin"])
async def add_group_to_user(request):
user_id = as_ulid(request.path_params["user_id"])
user = await db.get(User, id=str(user_id))
if not user:
return not_found("User not found")
(group_id, access) = await json_from_body(request, ["group", "access"])
group = await db.get(Group, id=str(group_id))
if not group:
return not_found("Group not found")
if access not in set("riw"):
raise HTTPException(422, f"Invalid access level.")
user.set_access(group_id, access)
await db.update(user)
return JSONResponse(asplain(user))
@route("/users/{user_id}/ratings") @route("/users/{user_id}/ratings")
@requires(["private"]) @requires(["private"])
async def ratings_for_user(request): async def ratings_for_user(request):
request.path_params["user_id"]
not_implemented()
@route("/users/{user_id}/ratings", methods=["PUT"]) @route("/users/{user_id}/ratings", methods=["PUT"])
@requires("authenticated") @requires("authenticated")
async def set_rating_for_user(request): async def set_rating_for_user(request):
request.path_params["user_id"]
not_implemented()
@route("/users/_reload_ratings", methods=["POST"]) @route("/users/_reload_ratings", methods=["POST"])
@ -284,69 +551,49 @@ async def load_imdb_user_ratings(request):
@route("/groups") @route("/groups")
@requires(["authenticated", "admin"]) @requires(["authenticated", "admin"])
async def list_groups(request): async def list_groups(request):
groups = await db.get_all(Group) groups = await db.get_all(Group)
return JSONResponse([asplain(g) for g in groups]) return JSONResponse([asplain(g) for g in groups])
@route("/groups", methods=["POST"]) @route("/groups", methods=["POST"])
@requires(["authenticated", "admin"]) @requires(["authenticated", "admin"])
async def add_group(request): async def add_group(request):
if not await request.body():
data = {}
else:
try:
data = await request.json()
except JSONDecodeError:
raise HTTPException(422, "Invalid JSON content.")
try: (name,) = await json_from_body(request, ["name"])
name = data["name"]
except KeyError as err:
raise HTTPException(422, f"Missing data for key: {err.args[0]}")
# XXX restrict name # XXX restrict name
secret = secrets.token_bytes() group = Group(name=name)
group = Group(name=name, secret=phc_scrypt(secret))
await db.add(group) await db.add(group)
return JSONResponse( return JSONResponse(asplain(group))
{
"secret": b64encode(secret),
"group": asplain(group),
}
)
@route("/groups/{group_id}/users", methods=["POST"]) @route("/groups/{group_id}/users", methods=["POST"])
@requires(["authenticated"]) @requires(["authenticated"])
async def add_user_to_group(request): async def add_user_to_group(request):
group_id = as_ulid(request.path_params["group_id"]) group_id = as_ulid(request.path_params["group_id"])
group = await db.get(Group, id=str(group_id)) group = await db.get(Group, id=str(group_id))
if not group: if not group:
return not_found() return not_found()
is_allowed = "admin" in request.auth.scopes or phc_compare( is_allowed = is_admin(request)
secret=request.user.token, phc_string=group.secret
) if not is_allowed:
user = await auth_user(request)
if not user:
return not_found("User not found.")
is_allowed = user.has_access(group_id, "w")
if not is_allowed: if not is_allowed:
return forbidden() return forbidden()
if not await request.body(): name, user_id = await json_from_body(request, ["name", "id"])
data = {}
else:
try:
data = await request.json()
except JSONDecodeError:
raise HTTPException(422, "Invalid JSON content.")
try:
name = data["name"]
user_id = data["id"]
except KeyError as err:
raise HTTPException(422, f"Missing data for key: {err.args[0]}")
# XXX check if user exists # XXX check if user exists
# XXX restrict name # XXX restrict name
@ -382,7 +629,7 @@ def create_app():
on_startup=[open_connection_pool], on_startup=[open_connection_pool],
on_shutdown=[close_connection_pool], on_shutdown=[close_connection_pool],
routes=[ routes=[
Mount("/api/v1", routes=route.registered), Mount(f"{config.api_base}v1", routes=route.registered),
], ],
middleware=[ middleware=[
Middleware(ResponseTimeMiddleware, header_name="Unwind-Elapsed"), Middleware(ResponseTimeMiddleware, header_name="Unwind-Elapsed"),
@ -391,6 +638,14 @@ def create_app():
backend=BearerAuthBackend(config.api_credentials), backend=BearerAuthBackend(config.api_credentials),
on_error=auth_error, on_error=auth_error,
), ),
Middleware(
CORSMiddleware,
allow_origins=[config.api_cors],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
),
Middleware(GZipMiddleware),
], ],
exception_handlers={HTTPException: http_exception}, exception_handlers={HTTPException: http_exception},
) )