diora-web/projects/views.py
mrwnslz e5a170838b
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Has been cancelled
Projekte mit Preset Forschung: Quellen (Paper per DOI, Bücher, Folgen mit Zeitmarke, Artikel), Zitierschlüssel, BibTeX/CSL-Export, Markdown-Notizen mit [@key]; Ende-zu-Ende-verschlüsselt. Navigation: Projekte als Tab am Handy, Suche als Lupe; sw v141
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:14:17 +02:00

285 lines
10 KiB
Python

"""Projekte (docs/projects.md): the API. Everything a project says is encrypted on the device;
here it's stored and handed back. A write names the IV it changed (base_iv): changed
elsewhere since, it's refused with the current state (409) for the device to merge."""
import json
import re
import requests
from django.core.cache import cache
from django.db import IntegrityError, transaction
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from .models import Project, ProjectItem
MAX_DATA = 2_000_000 # characters of base64: a long draft, not a PDF
MAX_PROJECTS = 200
MAX_ITEMS = 5000
def _auth(request):
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
return None
def _body(request):
try:
body = json.loads(request.body or b'{}')
except (json.JSONDecodeError, ValueError):
return None
return body if isinstance(body, dict) else None
def _hex_iv(value):
return isinstance(value, str) and 8 <= len(value) <= 32 and all(c in '0123456789abcdefABCDEF' for c in value)
def _blob(body):
"""(data_ct, data_iv) from a request, or None."""
ct, iv = body.get('data_ct'), body.get('data_iv')
if not isinstance(ct, str) or not ct or len(ct) > MAX_DATA or not _hex_iv(iv):
return None
return ct, iv
def _project_json(p, counts=None):
return {'id': p.id, 'preset': p.preset, 'data_ct': p.data_ct, 'data_iv': p.data_iv, 'archived': p.archived,
'position': p.position, 'created_at': p.created_at.isoformat(), 'updated_at': p.updated_at.isoformat(),
'items': (counts or {}).get(p.id, 0)}
def _item_json(i):
return {'id': i.id, 'project': i.project_id, 'kind': i.kind, 'ref': i.ref, 'data_ct': i.data_ct,
'data_iv': i.data_iv, 'created_at': i.created_at.isoformat(), 'updated_at': i.updated_at.isoformat()}
def _project(request, pk):
return Project.objects.filter(pk=pk, user=request.user).first()
# ---- projects ------------------------------------------------------------------------------
@require_http_methods(['GET'])
def projects(request):
"""All projects, and every link (kind, ref, project) — what a book's or an episode's sheet
shows as "in Projekt …"."""
err = _auth(request)
if err:
return err
rows = list(Project.objects.filter(user=request.user))
counts = {}
links = []
for pid, kind, ref in ProjectItem.objects.filter(project__user=request.user).values_list('project_id', 'kind', 'ref'):
counts[pid] = counts.get(pid, 0) + 1
if ref is not None:
links.append({'project': pid, 'kind': kind, 'ref': ref})
return JsonResponse({'projects': [_project_json(p, counts) for p in rows], 'links': links,
'presets': [k for k, _ in Project.PRESETS]})
@csrf_exempt
@require_http_methods(['POST'])
def create(request):
err = _auth(request)
if err:
return err
body = _body(request)
blob = body and _blob(body)
if not blob:
return JsonResponse({'error': 'data_ct, data_iv required'}, status=400)
preset = body.get('preset', Project.RESEARCH)
if preset not in dict(Project.PRESETS):
return JsonResponse({'error': 'unknown preset'}, status=400)
if Project.objects.filter(user=request.user).count() >= MAX_PROJECTS:
return JsonResponse({'error': 'too many projects'}, status=400)
p = Project.objects.create(user=request.user, preset=preset, data_ct=blob[0], data_iv=blob[1])
return JsonResponse({'project': _project_json(p)})
@csrf_exempt
@require_http_methods(['GET', 'POST'])
def project(request, pk):
"""GET: the project with its items. POST: new data (with base_iv), archived, position."""
err = _auth(request)
if err:
return err
p = _project(request, pk)
if not p:
return JsonResponse({'error': 'not found'}, status=404)
if request.method == 'GET':
items = list(p.items.all())
return JsonResponse({'project': _project_json(p, {p.id: len(items)}), 'items': [_item_json(i) for i in items]})
body = _body(request)
if body is None:
return JsonResponse({'error': 'invalid JSON'}, status=400)
fields = []
if 'data_ct' in body:
blob = _blob(body)
if not blob:
return JsonResponse({'error': 'data_ct, data_iv required'}, status=400)
base = body.get('base_iv')
if base and base != p.data_iv:
return JsonResponse({'error': 'stale', 'project': _project_json(p)}, status=409)
p.data_ct, p.data_iv = blob
fields += ['data_ct', 'data_iv']
if 'archived' in body:
p.archived = bool(body['archived'])
fields.append('archived')
if 'position' in body:
try:
p.position = int(body['position'])
except (TypeError, ValueError):
return JsonResponse({'error': 'position'}, status=400)
fields.append('position')
if fields:
p.save(update_fields=fields + ['updated_at'])
return JsonResponse({'project': _project_json(p)})
@csrf_exempt
@require_http_methods(['POST'])
def delete(request, pk):
err = _auth(request)
if err:
return err
p = _project(request, pk)
if not p:
return JsonResponse({'error': 'not found'}, status=404)
p.delete()
return JsonResponse({'ok': True})
# ---- items ---------------------------------------------------------------------------------
@csrf_exempt
@require_http_methods(['POST'])
def item_add(request, pk):
"""{kind, ref?, data_ct, data_iv}. A book, episode or article is in a project once: adding
it again answers with the item there (existing: true)."""
err = _auth(request)
if err:
return err
p = _project(request, pk)
if not p:
return JsonResponse({'error': 'not found'}, status=404)
body = _body(request)
blob = body and _blob(body)
if not blob:
return JsonResponse({'error': 'data_ct, data_iv required'}, status=400)
kind = body.get('kind')
if kind not in dict(ProjectItem.KINDS):
return JsonResponse({'error': 'kind'}, status=400)
ref = body.get('ref')
if kind in ProjectItem.LINKED:
if not isinstance(ref, int) or isinstance(ref, bool) or ref <= 0:
return JsonResponse({'error': 'ref required'}, status=400)
else:
ref = None
if p.items.count() >= MAX_ITEMS:
return JsonResponse({'error': 'too many items'}, status=400)
if ref is not None:
have = p.items.filter(kind=kind, ref=ref).first()
if have:
return JsonResponse({'item': _item_json(have), 'existing': True})
try:
with transaction.atomic():
i = ProjectItem.objects.create(project=p, kind=kind, ref=ref, data_ct=blob[0], data_iv=blob[1])
except IntegrityError:
return JsonResponse({'item': _item_json(p.items.get(kind=kind, ref=ref)), 'existing': True})
p.save(update_fields=['updated_at'])
return JsonResponse({'item': _item_json(i), 'existing': False})
def _item(request, pk):
return ProjectItem.objects.filter(pk=pk, project__user=request.user).select_related('project').first()
@csrf_exempt
@require_http_methods(['POST'])
def item_update(request, pk):
"""{data_ct, data_iv, base_iv}: a changed note or source; 409 with the current one when it
was changed elsewhere since."""
err = _auth(request)
if err:
return err
i = _item(request, pk)
if not i:
return JsonResponse({'error': 'not found'}, status=404)
body = _body(request)
blob = body and _blob(body)
if not blob:
return JsonResponse({'error': 'data_ct, data_iv required'}, status=400)
base = body.get('base_iv')
if base and base != i.data_iv:
return JsonResponse({'error': 'stale', 'item': _item_json(i)}, status=409)
i.data_ct, i.data_iv = blob
i.save(update_fields=['data_ct', 'data_iv', 'updated_at'])
i.project.save(update_fields=['updated_at'])
return JsonResponse({'item': _item_json(i)})
@csrf_exempt
@require_http_methods(['POST'])
def item_delete(request, pk):
err = _auth(request)
if err:
return err
i = _item(request, pk)
if not i:
return JsonResponse({'error': 'not found'}, status=404)
i.delete()
return JsonResponse({'ok': True})
# ---- a paper's data by its DOI ----------------------------------------------------------------
DOI_RE = re.compile(r'(10\.\d{4,9}/[^\s"<>]+)', re.I)
def doi_of(text):
"""The DOI in a DOI, a doi.org link, a publisher's link that carries it, or an arXiv id."""
text = (text or '').strip()
m = DOI_RE.search(text)
if m:
return m.group(1).rstrip('.,;)')
m = re.search(r'arxiv\.org/(?:abs|pdf)/(\d{4}\.\d{4,5})|^(?:arxiv:)?(\d{4}\.\d{4,5})(?:v\d+)?$', text, re.I)
if m:
return '10.48550/arXiv.' + (m.group(1) or m.group(2))
return None
@require_http_methods(['GET'])
def doi(request):
"""?q=<DOI, link or arXiv id>: the work's citation data (CSL-JSON) from doi.org — Crossref,
DataCite (arXiv) and the other registries answer there. Not kept beyond a cache."""
err = _auth(request)
if err:
return err
d = doi_of(request.GET.get('q', ''))
if not d:
return JsonResponse({'error': 'Keine DOI erkannt.'}, status=400)
key = 'doi:' + re.sub(r'\W+', '_', d.lower())[:200]
csl = cache.get(key)
if csl is None:
try:
r = requests.get('https://doi.org/' + d, timeout=15,
headers={'Accept': 'application/vnd.citationstyles.csl+json',
'User-Agent': 'diora (https://diora.creamfresh.xyz)'})
except requests.RequestException:
return JsonResponse({'error': 'doi.org nicht erreichbar.'}, status=502)
if r.status_code == 404:
return JsonResponse({'error': 'DOI unbekannt.'}, status=404)
try:
csl = r.json()
except ValueError:
return JsonResponse({'error': 'Keine Daten zu dieser DOI.'}, status=502)
if not isinstance(csl, dict):
return JsonResponse({'error': 'Keine Daten zu dieser DOI.'}, status=502)
# What a citation needs; the reference lists and licences stay out.
for k in ('reference', 'license', 'link', 'relation', 'assertion', 'funder', 'content-domain',
'indexed', 'deposited', 'update-policy', 'resource'):
csl.pop(k, None)
cache.set(key, csl, 7 * 24 * 3600)
return JsonResponse({'doi': d, 'csl': csl})