From 2fb9e7c39cdb81f592dd5a86ea5ea530020eb16d Mon Sep 17 00:00:00 2001 From: Joshua Laymon Date: Tue, 12 Aug 2025 21:53:03 -0500 Subject: [PATCH] Update --- .DS_Store | Bin 8196 -> 6148 bytes Illustrations | 1 - docker-compose.yml | 31 ++- imports/README.txt | 1 + web/.DS_Store | Bin 6148 -> 0 bytes web/Dockerfile | 6 +- web/core/admin.py | 5 +- web/core/apps.py | 1 - web/core/forms.py | 13 +- web/core/management/commands/import_seed.py | 17 ++ web/core/management/commands/init_users.py | 25 +- web/core/migrations/0001_initial.py | 3 +- web/core/models.py | 14 +- web/core/utils.py | 198 +++++++-------- web/core/views.py | 255 +++++++++++--------- web/entrypoint.sh | 20 ++ web/illustrations/settings.py | 1 - web/illustrations/urls.py | 14 +- web/illustrations/wsgi.py | 1 - web/manage.py | 1 - web/requirements.txt | 1 - web/templates/base.html | 71 +++--- web/templates/entry_delete_confirm.html | 12 + web/templates/entry_edit.html | 56 +++++ web/templates/entry_view.html | 42 ++++ web/templates/import_result.html | 3 +- web/templates/import_wizard.html | 1 - web/templates/login.html | 1 - web/templates/record.html | 101 -------- web/templates/search.html | 39 +-- web/templates/stats.html | 39 +++ 31 files changed, 554 insertions(+), 419 deletions(-) delete mode 160000 Illustrations create mode 100644 imports/README.txt delete mode 100644 web/.DS_Store create mode 100644 web/core/management/commands/import_seed.py create mode 100644 web/entrypoint.sh create mode 100644 web/templates/entry_delete_confirm.html create mode 100644 web/templates/entry_edit.html create mode 100644 web/templates/entry_view.html delete mode 100644 web/templates/record.html create mode 100644 web/templates/stats.html diff --git a/.DS_Store b/.DS_Store index 2477d8b97606409f65beaecd5f974e4d7761bb3f..90ed3551bde9152e39f12aa3ec7b2d034117b795 100644 GIT binary patch delta 196 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{Mvv5r;6q~50$jH4hU^g=(_hcRc_sJ&(`6sUu zv}D{lxkI>+Te7;^(#S$b!N}5LvZ0_1WA|h~L3zfW$@7F>B2<`JnCd8)S{NZzi1z@E zXJ#m8NM%SW3ogpb$TYZXn?ba>&NQ@640=WjsO7Vqk(e N5#(xy&G9^Qm;oWzDn0-J delta 363 zcmZoMXmOBWU|?W$DortDU;r^WfEYvza8E20o2aMA$hR?IH}hr%jz7$c**Q2SHn1@A zP3B>7Pvm9rWXJ)+QifuN5{4p%L?D^Tkk63EQ0$qLpPZDFp9C@nh}Q$L-hVIvvKScf z=u9dvE`aLXHQAo6kVmSz+R(_@NJqigz;g0#HW|jA$v4>KwR`a!Qx;s5my@5D4phZB zIe^8R5y>zkGaUspV-tj7jJ=aXSzjV6F)#+&XKaB`A`Y^TnW3B^l_3e)?9CB8+nBi| jxPh{+pdi~U$nl+dGQWuDWPctG4n|05GHj0LnZpbKz@Jy= diff --git a/Illustrations b/Illustrations deleted file mode 160000 index 5fb09a4..0000000 --- a/Illustrations +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5fb09a4946460415fa039475e05412b8936b424e diff --git a/docker-compose.yml b/docker-compose.yml index 4a55aa0..bd540bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,20 @@ - version: "3.9" services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: illustrations + POSTGRES_USER: illustrations + POSTGRES_PASSWORD: illustrations + volumes: + - illustrations_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 3s + timeout: 3s + retries: 20 + start_period: 5s + web: build: ./web env_file: @@ -11,14 +25,9 @@ services: ports: - "8000:8000" depends_on: - - db - db: - image: postgres:16-alpine - environment: - POSTGRES_DB: illustrations - POSTGRES_USER: illustrations - POSTGRES_PASSWORD: illustrations - volumes: - - db_data:/var/lib/postgresql/data + db: + condition: service_healthy + volumes: - db_data: + illustrations_db_data: + name: illustrations_db_data diff --git a/imports/README.txt b/imports/README.txt new file mode 100644 index 0000000..4f05328 --- /dev/null +++ b/imports/README.txt @@ -0,0 +1 @@ +Place your seed CSV here as illustrations_seed.csv with the proper headers. \ No newline at end of file diff --git a/web/.DS_Store b/web/.DS_Store deleted file mode 100644 index e949150490eb71b8e9deeed03d49bf7a20643269..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~&x_MQ6vy9mqnqrq(1U^qLcnWTyIaHsFR|8xS0j2*sYx5xu<1-~QmZV5+||Fu zyMKuPizofQnb~EVs(28QG7o0H^W(h<^O;O0LqwuKA0H64iO5G{^>&c`#JHc+nzd{T z#|a+eVWCE8p;FX&KxdACBk+F{;Jxe7h!o8zYwX{9=-dCUO;6(7FI|*VyU)L*(;_dc zZuhHbZF!e2Z~NQ+uKyx_trl^WRP(Z*%${-Wu~J2PR!-6Y-U1%KFzI8gc_PvRWHO3rv_$s0kJJiXrUHL6~s#N7lbKYQjnA zCu1D@WMOY8!d^T?sNG3KMqTa*I0CB#woJ9n=l_FW-~U>+I=11U8C*@CNaqk15%+ zb!~Ed)`mzgk=WR;HmXCA+2dFj_$a=Hqz!!`7od^V+9+CJ?ngjlaG4|UPYL`0 str: return BOOK_ALIASES.get(b, book_raw.strip()) def parse_scripture(s: str): - parts = [p.strip() for p in (s or "").split(";") if p.strip()] + parts = [p.strip() for p in s.split(";") if p.strip()] parsed = [] for p in parts: m = SCR_REF_RE.match(p) if not m: - parsed.append(None); continue + parsed.append(None) + continue book_raw, ch1, v1, ch2, v2 = m.groups() parsed.append({ "book": normalize_book(book_raw), @@ -51,113 +52,116 @@ def parse_scripture(s: str): }) return parsed -# CSV import --------------------------------------------------------- -EXPECTED_HEADERS = ["Subject","Illustration","Application","Scripture","Source","Talk Title","Talk Number","Code","Date","Date Edited"] - def parse_date(value): - if not value or not str(value).strip(): return None - try: return dateparser.parse(str(value)).date() - except Exception: return None + if not value or not str(value).strip(): + return None + try: + d = dateparser.parse(str(value)).date() + return d + except Exception: + return None -def import_csv(file_bytes: bytes, dry_run: bool=True): +EXPECTED_HEADERS = [ + "subject","illustration","application","scripture","source","talk number", + "talk title","code","date","date edited" +] + +def import_csv_bytes(file_bytes: bytes, dry_run: bool=True): text = file_bytes.decode("utf-8-sig") reader = csv.DictReader(io.StringIO(text)) - headers = reader.fieldnames or [] - # normalize - lower_map = {h.lower():h for h in headers} - required_lower = [h.lower() for h in EXPECTED_HEADERS] - missing = [orig for orig in EXPECTED_HEADERS if orig.lower() not in lower_map] + headers = [h.strip().lower() for h in reader.fieldnames or []] + missing = [h for h in EXPECTED_HEADERS if h not in headers] if missing: raise ValueError(f"Missing required headers: {missing}") - report = {"rows":0,"inserted":0,"updated":0,"skipped":0,"errors":[],"scripture_parsed":0,"scripture_failed":0} - rows = list(reader); report["rows"] = len(rows) - for r in rows: - try: - def get(name): - return r[ lower_map[name.lower()] ].strip() if r.get(lower_map[name.lower()]) is not None else "" + report = {"rows": 0,"inserted": 0,"updated": 0,"skipped": 0,"errors": [],"scripture_parsed": 0,"scripture_failed": 0} + rows = list(reader) + report["rows"] = len(rows) + + for row in rows: + try: + entry_code = (row.get("code") or "").strip() + talk_number = row.get("talk number") + try: + talk_number = int(talk_number) if str(talk_number).strip() else None + except Exception: + talk_number = None + + date_added = parse_date(row.get("date")) + date_edited = parse_date(row.get("date edited")) - entry_code = get("Code") data = dict( - subject=get("Subject"), - illustration=get("Illustration"), - application=get("Application"), - scripture_raw=get("Scripture"), - source=get("Source"), - talk_title=get("Talk Title"), - talk_number=int(get("Talk Number")) if get("Talk Number") else None, + subject=row.get("subject") or "", + illustration=row.get("illustration") or "", + application=row.get("application") or "", + scripture_raw=row.get("scripture") or "", + source=row.get("source") or "", + talk_number=talk_number, + talk_title=row.get("talk title") or "", entry_code=entry_code, - date_added=parse_date(get("Date")), - date_edited=parse_date(get("Date Edited")), + date_added=date_added, + date_edited=date_edited, ) + + from .models import Entry, ScriptureRef obj = None if entry_code: - try: obj = Entry.objects.get(entry_code=entry_code) - except Entry.DoesNotExist: obj = None + try: + obj = Entry.objects.get(entry_code=entry_code) + except Entry.DoesNotExist: + obj = None + + # parse scriptures for reporting + parsed_list = parse_scripture(data["scripture_raw"]) + for item in parsed_list: + if item: + report["scripture_parsed"] += 1 + else: + report["scripture_failed"] += 1 if not dry_run: if obj: - for k,v in data.items(): setattr(obj,k,v) + for k, v in data.items(): + setattr(obj, k, v) obj.save() obj.scripture_refs.all().delete() report["updated"] += 1 else: + from .models import Entry obj = Entry.objects.create(**data) report["inserted"] += 1 - for pr in parse_scripture(data["scripture_raw"]): - if pr: ScriptureRef.objects.create(entry=obj, **pr); report["scripture_parsed"] += 1 - else: report["scripture_failed"] += 1 - else: - for pr in parse_scripture(data["scripture_raw"]): - if pr: report["scripture_parsed"] += 1 - else: report["scripture_failed"] += 1 + + # persist parsed scripture refs + for item in parsed_list: + if item: + ScriptureRef.objects.create(entry=obj, **item) except Exception as e: report["skipped"] += 1 report["errors"].append(str(e)) + return report -# Search helpers ----------------------------------------------------- -SEARCHABLE_FIELDS = { - "Subject": "subject", - "Illustration": "illustration", - "Application": "application", - "Scripture": "scripture_raw", - "Source": "source", - "Talk Title": "talk_title", - "Talk Number": "talk_number", - "Code": "entry_code", -} +def wildcard_to_like(q: str) -> str: + # Convert * and ? to SQL LIKE wildcards + return q.replace("%","\%").replace("_","\_").replace("*","%").replace("?","_") -def wildcard_to_ilike(term:str)->str: - # Convert * ? to SQL ILIKE pattern - return term.replace('%','\%').replace('_','\_').replace('*','%').replace('?','_') +def terms(q: str): + return [t for t in q.split() if t.strip()] -def build_query(selected_fields, query_text): - # Split on spaces unless inside quotes - tokens = [] - buf = '' - in_quotes = False - for ch in query_text: - if ch == '"': in_quotes = not in_quotes; continue - if ch.isspace() and not in_quotes: - if buf: tokens.append(buf); buf='' +def month_buckets_last_12(today: date): + # returns list of (YYYY-MM, start, end) + months = [] + y, m = today.year, today.month + for i in range(12): + mm = m - i + yy = y + while mm <= 0: + mm += 12 + yy -= 1 + start = date(yy, mm, 1) + if mm == 12: + end = date(yy+1, 1, 1) else: - buf += ch - if buf: tokens.append(buf) - - # Build Q objects: AND across tokens, OR across fields for each token - q = Q() - for t in tokens: - pat = wildcard_to_ilike(t) - token_q = Q() - # OR across fields - for label in selected_fields: - col = SEARCHABLE_FIELDS[label] - if col == "talk_number" and pat.replace('%','').replace('_','').isdigit(): - try: - token_q |= Q(**{col: int(pat.replace('%','').replace('_',''))}) - except: pass - else: - token_q |= Q(**{f"{col}__icontains": t.replace('*','').replace('?','')}) | Q(**{f"{col}__iregex": pat.replace('%','.*').replace('_','.')}) - q &= token_q - return q + end = date(yy, mm+1, 1) + months.append((f"{yy}-{mm:02d}", start, end)) + return list(reversed(months)) diff --git a/web/core/views.py b/web/core/views.py index 7056d4b..4bf6e04 100644 --- a/web/core/views.py +++ b/web/core/views.py @@ -1,15 +1,16 @@ - from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth import authenticate, login from django.contrib.auth.decorators import login_required, user_passes_test -from django.http import HttpResponse, HttpResponseForbidden +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect from django.contrib import messages -from django.db.models import Q -import csv +from django.db.models import Q, Count from django.utils.timezone import now -from .forms import ImportForm +from datetime import date, timedelta +import csv, io + from .models import Entry -from .utils import import_csv, SEARCHABLE_FIELDS, build_query +from .forms import ImportForm, EntryForm +from .utils import import_csv_bytes, wildcard_to_like, terms, month_buckets_last_12 def is_admin(user): return user.is_superuser or user.is_staff @@ -25,132 +26,125 @@ def login_view(request): if user: login(request, user) return redirect("search") - ctx["error"] = "Invalid credentials" + else: + ctx["error"] = "Invalid credentials" return render(request, "login.html", ctx) @login_required -def redirect_to_search(request): - return redirect("search") +def search_page(request): + # Defaults: Subject, Illustration, Application checked + default_fields = {"subject": True, "illustration": True, "application": True, + "scripture_raw": False, "source": False, "talk_title": False, + "talk_number": False, "entry_code": False} + if request.method == "GET": + # read selection from query or use defaults + selected = {k: (request.GET.get(k,"on" if v else "") != "") for k,v in default_fields.items()} + q = request.GET.get("q","").strip() -@login_required -def search_view(request): - total = Entry.objects.count() - query = request.GET.get("q", "").strip() - selected = request.GET.getlist("fields") or list(SEARCHABLE_FIELDS.keys()) - entries = [] - results_count = 0 - current_id = None + results = [] + count = 0 + idx = 0 + if q: + like = wildcard_to_like(q) + term_list = terms(q) + # Build Q across selected fields, ANDing each term + fields = [f for f,sel in selected.items() if sel] + qs = Entry.objects.all() + for t in term_list: + pattern = wildcard_to_like(t) + clause = Q() + for f in fields: + clause |= Q(**{f+"__icontains": t.replace("*","").replace("?","")}) + qs = qs.filter(clause) + ids = list(qs.order_by("-date_added","-id").values_list("id", flat=True)) + request.session["result_ids"] = ids + count = len(ids) + if count: + idx = 0 + entry = Entry.objects.get(pk=ids[idx]) + return render(request, "entry_view.html", { + "entry": entry, "locked": True, + "position": idx+1, "count": count, "from_search": True + }) - if query: - q = build_query(selected, query) - entries = list(Entry.objects.filter(q).order_by("-date_added","-id").values_list("id", flat=True)) - results_count = len(entries) - request.session["search_ids"] = entries - request.session["search_index"] = 0 - if entries: - current_id = entries[0] - return redirect("record_view", entry_id=current_id) + # If no query or no results, render search page + total = Entry.objects.count() + return render(request, "search.html", { + "q": q, "selected": selected, "total": total + }) - return render(request, "search.html", { - "total": total, - "q": query, - "selected": selected, - "fields": list(SEARCHABLE_FIELDS.keys()), - "results_count": results_count, - }) - -@login_required -def record_view(request, entry_id): - ids = request.session.get("search_ids", []) - if entry_id in ids: - request.session["search_index"] = ids.index(entry_id) - idx = request.session.get("search_index", 0) - total = Entry.objects.count() - results_count = len(ids) - pos = (idx+1) if ids else 1 - entry = get_object_or_404(Entry, id=entry_id) - return render(request, "record.html", { - "entry": entry, - "locked": True, - "total": total, - "results_count": results_count, - "position": pos, - }) - -@login_required -def nav_prev(request): - ids = request.session.get("search_ids", []) - idx = request.session.get("search_index", 0) - if ids: - idx = max(0, idx-1) - request.session["search_index"] = idx - return redirect("record_view", entry_id=ids[idx]) - messages.info(request, "No search results loaded.") - return redirect("search") + return render(request, "search.html", {"selected": default_fields}) @login_required def nav_next(request): - ids = request.session.get("search_ids", []) - idx = request.session.get("search_index", 0) - if ids: - idx = min(len(ids)-1, idx+1) - request.session["search_index"] = idx - return redirect("record_view", entry_id=ids[idx]) - messages.info(request, "No search results loaded.") - return redirect("search") - -@login_required -def record_save(request, entry_id): - if request.method != "POST": - return redirect("record_view", entry_id=entry_id) - e = get_object_or_404(Entry, id=entry_id) - # Save edited fields - e.subject = request.POST.get("subject","") - e.illustration = request.POST.get("illustration","") - e.application = request.POST.get("application","") - e.scripture_raw = request.POST.get("scripture_raw","") - e.source = request.POST.get("source","") - e.talk_title = request.POST.get("talk_title","") - tn = request.POST.get("talk_number","").strip() - e.talk_number = int(tn) if tn.isdigit() else None - e.entry_code = request.POST.get("entry_code","") - e.date_added = request.POST.get("date_added") or None - e.date_edited = request.POST.get("date_edited") or None - e.save() - messages.success(request, "Saved changes.") - return redirect("record_view", entry_id=entry_id) - -@login_required -def record_delete(request, entry_id): - if request.method == "POST": - e = get_object_or_404(Entry, id=entry_id) - e.delete() - messages.success(request, "Entry deleted.") - # After delete, move to previous or search page - ids = request.session.get("search_ids", []) - idx = request.session.get("search_index", 0) - if ids: - ids = [i for i in ids if i != entry_id] - request.session["search_ids"] = ids - if not ids: - return redirect("search") - idx = max(0, min(idx, len(ids)-1)) - request.session["search_index"] = idx - return redirect("record_view", entry_id=ids[idx]) + ids = request.session.get("result_ids", []) + if not ids: return redirect("search") - return HttpResponseForbidden("Use POST to delete.") + idx = int(request.GET.get("i","0")) + idx = min(idx+1, len(ids)-1) + entry = get_object_or_404(Entry, pk=ids[idx]) + return render(request, "entry_view.html", {"entry": entry, "locked": True, "position": idx+1, "count": len(ids)}) + +@login_required +def nav_prev(request): + ids = request.session.get("result_ids", []) + if not ids: + return redirect("search") + idx = int(request.GET.get("i","0")) + idx = max(idx-1, 0) + entry = get_object_or_404(Entry, pk=ids[idx]) + return render(request, "entry_view.html", {"entry": entry, "locked": True, "position": idx+1, "count": len(ids)}) + +@login_required +def entry_view(request, entry_id): + entry = get_object_or_404(Entry, pk=entry_id) + ids = request.session.get("result_ids", []) + count = len(ids) + position = ids.index(entry.id)+1 if entry.id in ids else 1 + return render(request, "entry_view.html", {"entry": entry, "locked": True, "position": position, "count": count}) + +@login_required +def entry_edit(request, entry_id): + entry = get_object_or_404(Entry, pk=entry_id) + if request.method == "POST": + form = EntryForm(request.POST) + if form.is_valid(): + for k,v in form.cleaned_data.items(): + setattr(entry, k, v) + entry.save() + messages.success(request, "Entry saved.") + return redirect("entry_view", entry_id=entry.id) + else: + initial = { "subject": entry.subject, "illustration": entry.illustration, "application": entry.application, + "scripture_raw": entry.scripture_raw, "source": entry.source, "talk_number": entry.talk_number, + "talk_title": entry.talk_title, "entry_code": entry.entry_code, + "date_added": entry.date_added, "date_edited": entry.date_edited } + form = EntryForm(initial=initial) + ids = request.session.get("result_ids", []) + count = len(ids) + position = ids.index(entry.id)+1 if entry.id in ids else 1 + return render(request, "entry_edit.html", {"entry": entry, "form": form, "position": position, "count": count}) + +@login_required +def entry_delete(request, entry_id): + entry = get_object_or_404(Entry, pk=entry_id) + if request.method == "POST": + entry.delete() + messages.success(request, "Entry deleted.") + return redirect("search") + return render(request, "entry_delete_confirm.html", {"entry": entry}) @login_required @user_passes_test(is_admin) def import_wizard(request): - from .forms import ImportForm + from .utils import import_csv_bytes if request.method == "POST": form = ImportForm(request.POST, request.FILES) if form.is_valid(): - fbytes = form.cleaned_data["file"].read() + f = form.cleaned_data["file"].read() dry = form.cleaned_data["dry_run"] try: - report = import_csv(fbytes, dry_run=dry) + report = import_csv_bytes(f, dry_run=dry) return render(request, "import_result.html", {"report": report, "dry_run": dry}) except Exception as e: messages.error(request, f"Import failed: {e}") @@ -165,12 +159,43 @@ def export_csv(request): ts = now().strftime("%Y-%m-%d_%H-%M-%S") response['Content-Disposition'] = f'attachment; filename="illustrations_backup_{ts}.csv"' writer = csv.writer(response) - writer.writerow(["Subject","Illustration","Application","Scripture","Source","Talk Title","Talk Number","Code","Date","Date Edited"]) + writer.writerow(["Subject","Illustration","Application","Scripture","Source","Talk Number","Talk Title","Code","Date","Date Edited"]) for e in Entry.objects.all().order_by("id"): writer.writerow([ e.subject, e.illustration, e.application, e.scripture_raw, e.source, - e.talk_title, e.talk_number if e.talk_number is not None else "", e.entry_code, + e.talk_number if e.talk_number is not None else "", + e.talk_title, e.entry_code, e.date_added.isoformat() if e.date_added else "", e.date_edited.isoformat() if e.date_edited else "", ]) return response + +@login_required +def stats_page(request): + total = Entry.objects.count() + today = date.today() + last30 = Entry.objects.filter(date_added__gte=today - timedelta(days=30)).count() + last365 = Entry.objects.filter(date_added__gte=today - timedelta(days=365)).count() + + # Sparkline: counts per month for last 12 months (by date_added, blanks excluded) + buckets = month_buckets_last_12(today) + series = [] + for label, start, end in buckets: + c = Entry.objects.filter(date_added__gte=start, date_added__lt=end).count() + series.append((label, c)) + + # Top subjects (split by commas, case-insensitive) + from collections import Counter + counts = Counter() + for subj in Entry.objects.exclude(subject="").values_list("subject", flat=True): + for tag in [t.strip() for t in subj.split(",") if t.strip()]: + counts[tag.lower()] += 1 + top_subjects = [{"name": n.title(), "count": c} for n,c in counts.most_common(10)] + + return render(request, "stats.html", { + "total": total, + "last30": last30, + "last365": last365, + "series": series, + "top_subjects": top_subjects + }) diff --git a/web/entrypoint.sh b/web/entrypoint.sh new file mode 100644 index 0000000..90f8d15 --- /dev/null +++ b/web/entrypoint.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -e + +echo "Waiting for Postgres to be ready..." +until pg_isready -h db -p 5432 -U "${POSTGRES_USER:-illustrations}" >/dev/null 2>&1; do + sleep 1 +done +echo "Postgres is ready." + +python manage.py migrate +python manage.py init_users + +if [ "${IMPORT_SEED_ON_START}" = "true" ] && [ -f "${SEED_CSV}" ] && [ ! -f /data/imports/.seed_done ]; then + echo "Seeding database from ${SEED_CSV}..." + python manage.py import_seed "${SEED_CSV}" + touch /data/imports/.seed_done + echo "Seed import complete." +fi + +python manage.py runserver 0.0.0.0:8000 diff --git a/web/illustrations/settings.py b/web/illustrations/settings.py index f4a0480..1155a5c 100644 --- a/web/illustrations/settings.py +++ b/web/illustrations/settings.py @@ -1,4 +1,3 @@ - import os from pathlib import Path diff --git a/web/illustrations/urls.py b/web/illustrations/urls.py index 58e5db5..78f85c2 100644 --- a/web/illustrations/urls.py +++ b/web/illustrations/urls.py @@ -1,4 +1,3 @@ - from django.contrib import admin from django.urls import path from django.contrib.auth import views as auth_views @@ -8,13 +7,14 @@ urlpatterns = [ path("admin/", admin.site.urls), path("login/", core_views.login_view, name="login"), path("logout/", auth_views.LogoutView.as_view(), name="logout"), - path("", core_views.redirect_to_search), - path("search/", core_views.search_view, name="search"), - path("record//", core_views.record_view, name="record_view"), - path("record//save/", core_views.record_save, name="record_save"), - path("record//delete/", core_views.record_delete, name="record_delete"), - path("nav/prev/", core_views.nav_prev, name="nav_prev"), + path("", core_views.search_page, name="home"), + path("search/", core_views.search_page, name="search"), + path("entry//", core_views.entry_view, name="entry_view"), + path("entry//edit/", core_views.entry_edit, name="entry_edit"), + path("entry//delete/", core_views.entry_delete, name="entry_delete"), path("nav/next/", core_views.nav_next, name="nav_next"), + path("nav/prev/", core_views.nav_prev, name="nav_prev"), path("import/", core_views.import_wizard, name="import_wizard"), path("export/csv/", core_views.export_csv, name="export_csv"), + path("stats/", core_views.stats_page, name="stats"), ] diff --git a/web/illustrations/wsgi.py b/web/illustrations/wsgi.py index 057e7b4..20224fc 100644 --- a/web/illustrations/wsgi.py +++ b/web/illustrations/wsgi.py @@ -1,4 +1,3 @@ - import os from django.core.wsgi import get_wsgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'illustrations.settings') diff --git a/web/manage.py b/web/manage.py index 92e80ef..a3aa13e 100644 --- a/web/manage.py +++ b/web/manage.py @@ -1,4 +1,3 @@ - #!/usr/bin/env python import os, sys def main(): diff --git a/web/requirements.txt b/web/requirements.txt index 23c71a2..46ec289 100644 --- a/web/requirements.txt +++ b/web/requirements.txt @@ -1,4 +1,3 @@ - Django==5.0.6 psycopg2-binary==2.9.9 python-dateutil==2.9.0.post0 diff --git a/web/templates/base.html b/web/templates/base.html index 5858a93..22489f0 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -1,4 +1,3 @@ - @@ -6,32 +5,39 @@ {% block title %}Illustrations DB{% endblock %} @@ -40,16 +46,17 @@
Illustrations Database
{% endif %}
- {% for message in messages %}
{{ message }}
{% endfor %} + {% for message in messages %} +
{{ message }}
+ {% endfor %} {% block content %}{% endblock %}
diff --git a/web/templates/entry_delete_confirm.html b/web/templates/entry_delete_confirm.html new file mode 100644 index 0000000..d9aee05 --- /dev/null +++ b/web/templates/entry_delete_confirm.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}Delete Entry - Illustrations DB{% endblock %} +{% block content %} +
+

Delete Entry

+

Are you sure you want to permanently delete {{ entry.talk_title|default:'(untitled)' }} (Code: {{ entry.entry_code }})?

+
{% csrf_token %} + Cancel + +
+
+{% endblock %} diff --git a/web/templates/entry_edit.html b/web/templates/entry_edit.html new file mode 100644 index 0000000..cd1c8d0 --- /dev/null +++ b/web/templates/entry_edit.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}Edit Entry - Illustrations DB{% endblock %} +{% block content %} +
+
Editing: Record {{ position }} of {{ count }}
+
+ Cancel +
+
+
{% csrf_token %} +
+

Edit Entry

+
+
+ + {{ form.talk_title }} +
+
+ + {{ form.talk_number }} +
+
+
+
+ + {{ form.source }} +
+
+ + {{ form.entry_code }} +
+
+ + {{ form.subject }} + + {{ form.illustration }} + + {{ form.application }} +
+
+ + {{ form.scripture_raw }} +
+
+ + {{ form.date_added }} + + {{ form.date_edited }} +
+
+
+ +
+
+
+{% endblock %} diff --git a/web/templates/entry_view.html b/web/templates/entry_view.html new file mode 100644 index 0000000..74ba937 --- /dev/null +++ b/web/templates/entry_view.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Entry - Illustrations DB{% endblock %} +{% block content %} +
+
Viewing: Record {{ position }} of {{ count }}
+ +
+ +
+

{{ entry.talk_title|default:"(untitled)" }}

+
Code: {{ entry.entry_code }} • Source: {{ entry.source }} • Talk # {{ entry.talk_number }}
+
+
+
+ +
+ {% for t in entry.subject.split(',') %}{% if t.strip %}{{ t.strip }}{% endif %}{% endfor %} +
+
+
+ +
+ {% for t in entry.scripture_raw.split(';') %}{% if t.strip %}{{ t.strip }}{% endif %}{% endfor %} +
+
+
+
+ +
{{ entry.illustration|linebreaksbr }}
+
+ +
{{ entry.application|linebreaksbr }}
+
+
Date Added: {{ entry.date_added }} • Date Edited: {{ entry.date_edited }}
+
+{% endblock %} diff --git a/web/templates/import_result.html b/web/templates/import_result.html index 22af6c5..93a1f0f 100644 --- a/web/templates/import_result.html +++ b/web/templates/import_result.html @@ -1,4 +1,3 @@ - {% extends "base.html" %} {% block title %}Import Result - Illustrations DB{% endblock %} {% block content %} @@ -14,7 +13,7 @@ {% if report.errors and report.errors|length %}

Errors

-
{{ report.errors|join("\n") }}
+
{{ report.errors|join("\n") }}
{% endif %}
Run again diff --git a/web/templates/import_wizard.html b/web/templates/import_wizard.html index 5db9ae2..d18b37b 100644 --- a/web/templates/import_wizard.html +++ b/web/templates/import_wizard.html @@ -1,4 +1,3 @@ - {% extends "base.html" %} {% block title %}Import Data - Illustrations DB{% endblock %} {% block content %} diff --git a/web/templates/login.html b/web/templates/login.html index cd1a198..34572d9 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -1,4 +1,3 @@ - {% extends "base.html" %} {% block title %}Sign in - Illustrations DB{% endblock %} {% block content %} diff --git a/web/templates/record.html b/web/templates/record.html deleted file mode 100644 index 3c47bae..0000000 --- a/web/templates/record.html +++ /dev/null @@ -1,101 +0,0 @@ - -{% extends "base.html" %} -{% block title %}Record - Illustrations DB{% endblock %} -{% block content %} -
-
Total: {{ total }}
-
Results: {{ results_count }}
-
Viewing: {{ position }} of {{ results_count|default:1 }}
-
- -
-
- -
- -
- {% csrf_token %} - -
-
-
- -
- {% csrf_token %} -
-
- - -
-
- - -
-
- -
- - -
- -
- - -
- -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
-
- - -
-
- - -
-
-
- -
- Back to Search - -
-
-
- - -{% endblock %} diff --git a/web/templates/search.html b/web/templates/search.html index 3fec5e5..51bc333 100644 --- a/web/templates/search.html +++ b/web/templates/search.html @@ -1,36 +1,41 @@ - {% extends "base.html" %} {% block title %}Search - Illustrations DB{% endblock %} {% block content %}
+

Search

+

How to search: Type words or phrases, use wildcards, and choose which fields to search. +
Examples: faith finds entries containing “faith”; *faith* uses wildcards; "exact phrase" matches the phrase; multiple words are ANDed (e.g., faith loyalty).

- - + +
-
- {% for f in fields %} - +
+ {% for field,label,checked in [ + ('subject','Subject', selected.subject), + ('illustration','Illustration', selected.illustration), + ('application','Application', selected.application), + ('scripture_raw','Scripture', selected.scripture_raw), + ('source','Source', selected.source), + ('talk_title','Talk Title', selected.talk_title), + ('talk_number','Talk Number', selected.talk_number), + ('entry_code','Code', selected.entry_code), + ] %} + {% endfor %}
-
- Clear +
-
-
Total entries: {{ total }}
- {% if results_count %}
Results: {{ results_count }}
{% endif %} -
- {% if results_count %} -
-

Opening first result…

-
- {% endif %} +
+
Total entries in database: {{ total }}
{% endblock %} diff --git a/web/templates/stats.html b/web/templates/stats.html new file mode 100644 index 0000000..b438e80 --- /dev/null +++ b/web/templates/stats.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Statistics - Illustrations DB{% endblock %} +{% block content %} +
+

Statistics

+
+
Total entries
{{ total }}
+
New in last 30 days
{{ last30 }}
+
New in last 365 days
{{ last365 }}
+
+
+

Trend (last 12 months)

+
+
Entries per month (by Date Added)
+
+ {% with maxv=series|last|slice:":1" %}{% endwith %} + {% with peak=series|map:'1' %}{% endwith %} + {% for label, value in series %} + {% with h=(value|add:0) %} +
+ {% endwith %} + {% endfor %} +
+
+ {% for label, value in series %}{{ label }}{% endfor %} +
+
+
+

Top Subjects

+
+ {% for item in top_subjects %} +
+
{{ item.name }}
+
{{ item.count }} entries
+
+ {% endfor %} +
+
+{% endblock %}