This commit is contained in:
Joshua Laymon 2025-08-12 21:53:03 -05:00
parent 97da3bd6c5
commit 2fb9e7c39c
31 changed files with 554 additions and 419 deletions

BIN
.DS_Store vendored

Binary file not shown.

@ -1 +0,0 @@
Subproject commit 5fb09a4946460415fa039475e05412b8936b424e

View File

@ -1,6 +1,20 @@
version: "3.9" version: "3.9"
services: 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: web:
build: ./web build: ./web
env_file: env_file:
@ -11,14 +25,9 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
depends_on: depends_on:
- db db:
db: condition: service_healthy
image: postgres:16-alpine
environment:
POSTGRES_DB: illustrations
POSTGRES_USER: illustrations
POSTGRES_PASSWORD: illustrations
volumes:
- db_data:/var/lib/postgresql/data
volumes: volumes:
db_data: illustrations_db_data:
name: illustrations_db_data

1
imports/README.txt Normal file
View File

@ -0,0 +1 @@
Place your seed CSV here as illustrations_seed.csv with the proper headers.

BIN
web/.DS_Store vendored

Binary file not shown.

View File

@ -1,4 +1,3 @@
FROM python:3.12-slim FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
@ -6,11 +5,12 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev postgresql-client && rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/ COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/ COPY . /app/
RUN chmod +x /app/entrypoint.sh
CMD ["bash", "-lc", "python manage.py migrate && python manage.py init_users && python manage.py runserver 0.0.0.0:8000"] CMD ["bash","-lc","/app/entrypoint.sh"]

View File

@ -1,4 +1,3 @@
from django.contrib import admin from django.contrib import admin
from .models import Entry, ScriptureRef from .models import Entry, ScriptureRef
@ -8,6 +7,6 @@ class ScriptureInline(admin.TabularInline):
@admin.register(Entry) @admin.register(Entry)
class EntryAdmin(admin.ModelAdmin): class EntryAdmin(admin.ModelAdmin):
list_display = ("talk_title","entry_code","source","date_added","date_edited") list_display = ("talk_title", "entry_code", "source", "date_added", "date_edited")
search_fields = ("talk_title","entry_code","source","subject","illustration","application","scripture_raw") search_fields = ("talk_title", "entry_code", "source", "subject", "illustration", "application", "scripture_raw")
inlines = [ScriptureInline] inlines = [ScriptureInline]

View File

@ -1,4 +1,3 @@
from django.apps import AppConfig from django.apps import AppConfig
class CoreConfig(AppConfig): class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"

View File

@ -1,6 +1,17 @@
from django import forms from django import forms
class ImportForm(forms.Form): class ImportForm(forms.Form):
file = forms.FileField(allow_empty_file=False) file = forms.FileField(allow_empty_file=False)
dry_run = forms.BooleanField(initial=True, required=False, help_text="Preview changes without saving") dry_run = forms.BooleanField(initial=True, required=False, help_text="Preview changes without saving")
class EntryForm(forms.Form):
subject = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows":2}))
illustration = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows":6}))
application = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows":6}))
scripture_raw = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows":2}))
source = forms.CharField(required=False)
talk_number = forms.IntegerField(required=False)
talk_title = forms.CharField(required=False)
entry_code = forms.CharField(required=False)
date_added = forms.DateField(required=False, input_formats=["%Y-%m-%d","%Y/%m/%d"])
date_edited = forms.DateField(required=False, input_formats=["%Y-%m-%d","%Y/%m/%d"])

View File

@ -0,0 +1,17 @@
from django.core.management.base import BaseCommand, CommandError
from pathlib import Path
from core.utils import import_csv_bytes
class Command(BaseCommand):
help = "Import seed CSV (idempotent upsert by Code)."
def add_arguments(self, parser):
parser.add_argument("path", nargs="?", default="/data/imports/illustrations_seed.csv")
parser.add_argument("--dry-run", action="store_true")
def handle(self, path, dry_run, *args, **kwargs):
p = Path(path)
if not p.exists():
raise CommandError(f"CSV not found: {p}")
report = import_csv_bytes(p.read_bytes(), dry_run=dry_run)
self.stdout.write(self.style.SUCCESS(str(report)))

View File

@ -1,27 +1,28 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.contrib.auth.models import User from django.contrib.auth.models import User
import os import os
class Command(BaseCommand): class Command(BaseCommand):
def handle(self, *args, **kwargs): help = "Create or update initial users from environment variables."
admin_user = os.getenv("INIT_ADMIN_USERNAME")
admin_pass = os.getenv("INIT_ADMIN_PASSWORD") def handle(self, *args, **options):
editor_user = os.getenv("INIT_EDITOR_USERNAME") admin_user = os.getenv("ADMIN_USERNAME")
editor_pass = os.getenv("INIT_EDITOR_PASSWORD") admin_pass = os.getenv("ADMIN_PASSWORD")
editor_user = os.getenv("USER_USERNAME")
editor_pass = os.getenv("USER_PASSWORD")
if admin_user and admin_pass: if admin_user and admin_pass:
u, created = User.objects.get_or_create(username=admin_user) u, created = User.objects.get_or_create(username=admin_user)
u.is_staff = True u.is_staff = True
u.is_superuser = True u.is_superuser = True
if admin_pass: u.set_password(admin_pass)
u.set_password(admin_pass)
u.save() u.save()
self.stdout.write(self.style.SUCCESS(f"Admin ready: {admin_user}")) self.stdout.write(self.style.SUCCESS(f"Admin user ensured: {admin_user}"))
if editor_user and editor_pass: if editor_user and editor_pass:
u, created = User.objects.get_or_create(username=editor_user) u, created = User.objects.get_or_create(username=editor_user)
u.is_staff = False u.is_staff = False
u.is_superuser = False u.is_superuser = False
if editor_pass: u.set_password(editor_pass)
u.set_password(editor_pass)
u.save() u.save()
self.stdout.write(self.style.SUCCESS(f"Editor ready: {editor_user}")) self.stdout.write(self.style.SUCCESS(f"Editor user ensured: {editor_user}"))

View File

@ -1,4 +1,3 @@
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@ -15,8 +14,8 @@ class Migration(migrations.Migration):
('application', models.TextField(blank=True)), ('application', models.TextField(blank=True)),
('scripture_raw', models.TextField(blank=True)), ('scripture_raw', models.TextField(blank=True)),
('source', models.CharField(blank=True, max_length=255)), ('source', models.CharField(blank=True, max_length=255)),
('talk_title', models.CharField(blank=True, max_length=255)),
('talk_number', models.IntegerField(blank=True, null=True)), ('talk_number', models.IntegerField(blank=True, null=True)),
('talk_title', models.CharField(blank=True, max_length=255)),
('entry_code', models.CharField(blank=True, db_index=True, max_length=64)), ('entry_code', models.CharField(blank=True, db_index=True, max_length=64)),
('date_added', models.DateField(blank=True, null=True)), ('date_added', models.DateField(blank=True, null=True)),
('date_edited', models.DateField(blank=True, null=True)), ('date_edited', models.DateField(blank=True, null=True)),

View File

@ -1,18 +1,16 @@
from django.db import models from django.db import models
class Entry(models.Model): class Entry(models.Model):
# Field names aligned to CSV headers (case-insensitive mapping in importer)
subject = models.TextField(blank=True) subject = models.TextField(blank=True)
illustration = models.TextField(blank=True) illustration = models.TextField(blank=True)
application = models.TextField(blank=True) application = models.TextField(blank=True)
scripture_raw = models.TextField(blank=True) # from CSV 'Scripture' scripture_raw = models.TextField(blank=True)
source = models.CharField(max_length=255, blank=True) source = models.CharField(max_length=255, blank=True)
talk_title = models.CharField(max_length=255, blank=True) # 'Talk Title' talk_number = models.IntegerField(null=True, blank=True)
talk_number = models.IntegerField(null=True, blank=True) # 'Talk Number' talk_title = models.CharField(max_length=255, blank=True)
entry_code = models.CharField(max_length=64, blank=True, db_index=True) # 'Code' entry_code = models.CharField(max_length=64, blank=True, db_index=True)
date_added = models.DateField(null=True, blank=True) # 'Date' date_added = models.DateField(null=True, blank=True)
date_edited = models.DateField(null=True, blank=True) # 'Date Edited' date_edited = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View File

@ -1,32 +1,32 @@
import csv, io, re, calendar
import csv, io, re from datetime import datetime, timedelta, date
from datetime import datetime
from dateutil import parser as dateparser from dateutil import parser as dateparser
from django.db.models import Q from collections import Counter, defaultdict
from django.db.models.functions import TruncMonth
from django.db.models import Count
from .models import Entry, ScriptureRef from .models import Entry, ScriptureRef
# Scripture parsing --------------------------------------------------
BOOK_ALIASES = { BOOK_ALIASES = {
"gen":"Genesis","ge":"Genesis","ex":"Exodus","lev":"Leviticus","num":"Numbers","deut":"Deuteronomy", "gen": "Genesis","ex": "Exodus","lev": "Leviticus","num": "Numbers","deut": "Deuteronomy",
"josh":"Joshua","judg":"Judges","rut":"Ruth","1sam":"1 Samuel","2sam":"2 Samuel", "josh": "Joshua","judg":"Judges","rut":"Ruth","1sam":"1 Samuel","2sam":"2 Samuel",
"1kings":"1 Kings","2kings":"2 Kings","1chron":"1 Chronicles","2chron":"2 Chronicles", "1kings":"1 Kings","2kings":"2 Kings","1chron":"1 Chronicles","2chron":"2 Chronicles",
"ezra":"Ezra","neh":"Nehemiah","esth":"Esther","job":"Job","ps":"Psalms","psa":"Psalms","prov":"Proverbs", "ezra":"Ezra","neh":"Nehemiah","esth":"Esther","job":"Job","ps":"Psalms","prov":"Proverbs",
"eccl":"Ecclesiastes","song":"Song of Solomon","isa":"Isaiah","jer":"Jeremiah","lam":"Lamentations", "eccl":"Ecclesiastes","song":"Song of Solomon","isa":"Isaiah","jer":"Jeremiah","lam":"Lamentations",
"ezek":"Ezekiel","dan":"Daniel","hos":"Hosea","joel":"Joel","amos":"Amos","obad":"Obadiah", "ezek":"Ezekiel","dan":"Daniel","hos":"Hosea","joel":"Joel","amos":"Amos","obad":"Obadiah",
"jon":"Jonah","mic":"Micah","nah":"Nahum","hab":"Habakkuk","zeph":"Zephaniah","hag":"Haggai", "jon":"Jonah","mic":"Micah","nah":"Nahum","hab":"Habakkuk","zeph":"Zephaniah","hag":"Haggai",
"zech":"Zechariah","mal":"Malachi","matt":"Matthew","mt":"Matthew","mark":"Mark","mk":"Mark","lk":"Luke", "zech":"Zechariah","mal":"Malachi","matt":"Matthew","mt":"Matthew","mark":"Mark","lk":"Luke",
"luke":"Luke","jn":"John","john":"John","acts":"Acts","rom":"Romans","1cor":"1 Corinthians", "luke":"Luke","jn":"John","john":"John","acts":"Acts","rom":"Romans","1cor":"1 Corinthians",
"2cor":"2 Corinthians","gal":"Galatians","eph":"Ephesians","phil":"Philippians","col":"Colossians", "2cor":"2 Corinthians","gal":"Galatians","eph":"Ephesians","phil":"Philippians","col":"Colossians",
"1thess":"1 Thessalonians","2thess":"2 Thessalonians","1tim":"1 Timothy","2tim":"2 Timothy", "1thess":"1 Thessalonians","2thess":"2 Thessalonians","1tim":"1 Timothy","2tim":"2 Timothy",
"titus":"Titus","phlm":"Philemon","heb":"Hebrews","jas":"James","jam":"James","1pet":"1 Peter","2pet":"2 Peter", "titus":"Titus","phlm":"Philemon","heb":"Hebrews","jas":"James","1pet":"1 Peter","2pet":"2 Peter",
"1john":"1 John","2john":"2 John","3john":"3 John","jude":"Jude","rev":"Revelation","re":"Revelation", "1john":"1 John","2john":"2 John","3john":"3 John","jude":"Jude","rev":"Revelation",
} }
SCR_REF_RE = re.compile(r""" SCR_REF_RE = re.compile(r"""
^\s*([1-3]?\s*[A-Za-z\.]+)\s+ # book ^\s*([1-3]?\s*[A-Za-z\.]+)\s+
(\d+) # chapter start (\d+)
(?::(\d+))? # verse start (?::(\d+))?
(?:\s*[-]\s*(\d+)(?::(\d+))?)? # optional range (?:\s*[-]\s*(\d+)(?::(\d+))?)?
\s*$ \s*$
""", re.VERBOSE) """, re.VERBOSE)
@ -35,12 +35,13 @@ def normalize_book(book_raw:str) -> str:
return BOOK_ALIASES.get(b, book_raw.strip()) return BOOK_ALIASES.get(b, book_raw.strip())
def parse_scripture(s: str): 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 = [] parsed = []
for p in parts: for p in parts:
m = SCR_REF_RE.match(p) m = SCR_REF_RE.match(p)
if not m: if not m:
parsed.append(None); continue parsed.append(None)
continue
book_raw, ch1, v1, ch2, v2 = m.groups() book_raw, ch1, v1, ch2, v2 = m.groups()
parsed.append({ parsed.append({
"book": normalize_book(book_raw), "book": normalize_book(book_raw),
@ -51,113 +52,116 @@ def parse_scripture(s: str):
}) })
return parsed return parsed
# CSV import ---------------------------------------------------------
EXPECTED_HEADERS = ["Subject","Illustration","Application","Scripture","Source","Talk Title","Talk Number","Code","Date","Date Edited"]
def parse_date(value): def parse_date(value):
if not value or not str(value).strip(): return None if not value or not str(value).strip():
try: return dateparser.parse(str(value)).date() return None
except Exception: 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") text = file_bytes.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text)) reader = csv.DictReader(io.StringIO(text))
headers = reader.fieldnames or [] headers = [h.strip().lower() for h in reader.fieldnames or []]
# normalize missing = [h for h in EXPECTED_HEADERS if h not in headers]
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]
if missing: if missing:
raise ValueError(f"Missing required headers: {missing}") raise ValueError(f"Missing required headers: {missing}")
report = {"rows":0,"inserted":0,"updated":0,"skipped":0,"errors":[],"scripture_parsed":0,"scripture_failed":0} report = {"rows": 0,"inserted": 0,"updated": 0,"skipped": 0,"errors": [],"scripture_parsed": 0,"scripture_failed": 0}
rows = list(reader); report["rows"] = len(rows) rows = list(reader)
for r in rows: report["rows"] = len(rows)
try:
def get(name): for row in rows:
return r[ lower_map[name.lower()] ].strip() if r.get(lower_map[name.lower()]) is not None else "" 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( data = dict(
subject=get("Subject"), subject=row.get("subject") or "",
illustration=get("Illustration"), illustration=row.get("illustration") or "",
application=get("Application"), application=row.get("application") or "",
scripture_raw=get("Scripture"), scripture_raw=row.get("scripture") or "",
source=get("Source"), source=row.get("source") or "",
talk_title=get("Talk Title"), talk_number=talk_number,
talk_number=int(get("Talk Number")) if get("Talk Number") else None, talk_title=row.get("talk title") or "",
entry_code=entry_code, entry_code=entry_code,
date_added=parse_date(get("Date")), date_added=date_added,
date_edited=parse_date(get("Date Edited")), date_edited=date_edited,
) )
from .models import Entry, ScriptureRef
obj = None obj = None
if entry_code: if entry_code:
try: obj = Entry.objects.get(entry_code=entry_code) try:
except Entry.DoesNotExist: obj = None 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 not dry_run:
if obj: 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.save()
obj.scripture_refs.all().delete() obj.scripture_refs.all().delete()
report["updated"] += 1 report["updated"] += 1
else: else:
from .models import Entry
obj = Entry.objects.create(**data) obj = Entry.objects.create(**data)
report["inserted"] += 1 report["inserted"] += 1
for pr in parse_scripture(data["scripture_raw"]):
if pr: ScriptureRef.objects.create(entry=obj, **pr); report["scripture_parsed"] += 1 # persist parsed scripture refs
else: report["scripture_failed"] += 1 for item in parsed_list:
else: if item:
for pr in parse_scripture(data["scripture_raw"]): ScriptureRef.objects.create(entry=obj, **item)
if pr: report["scripture_parsed"] += 1
else: report["scripture_failed"] += 1
except Exception as e: except Exception as e:
report["skipped"] += 1 report["skipped"] += 1
report["errors"].append(str(e)) report["errors"].append(str(e))
return report return report
# Search helpers ----------------------------------------------------- def wildcard_to_like(q: str) -> str:
SEARCHABLE_FIELDS = { # Convert * and ? to SQL LIKE wildcards
"Subject": "subject", return q.replace("%","\%").replace("_","\_").replace("*","%").replace("?","_")
"Illustration": "illustration",
"Application": "application",
"Scripture": "scripture_raw",
"Source": "source",
"Talk Title": "talk_title",
"Talk Number": "talk_number",
"Code": "entry_code",
}
def wildcard_to_ilike(term:str)->str: def terms(q: str):
# Convert * ? to SQL ILIKE pattern return [t for t in q.split() if t.strip()]
return term.replace('%','\%').replace('_','\_').replace('*','%').replace('?','_')
def build_query(selected_fields, query_text): def month_buckets_last_12(today: date):
# Split on spaces unless inside quotes # returns list of (YYYY-MM, start, end)
tokens = [] months = []
buf = '' y, m = today.year, today.month
in_quotes = False for i in range(12):
for ch in query_text: mm = m - i
if ch == '"': in_quotes = not in_quotes; continue yy = y
if ch.isspace() and not in_quotes: while mm <= 0:
if buf: tokens.append(buf); buf='' mm += 12
yy -= 1
start = date(yy, mm, 1)
if mm == 12:
end = date(yy+1, 1, 1)
else: else:
buf += ch end = date(yy, mm+1, 1)
if buf: tokens.append(buf) months.append((f"{yy}-{mm:02d}", start, end))
return list(reversed(months))
# 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

View File

@ -1,15 +1,16 @@
from django.shortcuts import render, redirect, get_object_or_404 from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth import authenticate, login from django.contrib.auth import authenticate, login
from django.contrib.auth.decorators import login_required, user_passes_test 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.contrib import messages
from django.db.models import Q from django.db.models import Q, Count
import csv
from django.utils.timezone import now from django.utils.timezone import now
from .forms import ImportForm from datetime import date, timedelta
import csv, io
from .models import Entry 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): def is_admin(user):
return user.is_superuser or user.is_staff return user.is_superuser or user.is_staff
@ -25,132 +26,125 @@ def login_view(request):
if user: if user:
login(request, user) login(request, user)
return redirect("search") return redirect("search")
ctx["error"] = "Invalid credentials" else:
ctx["error"] = "Invalid credentials"
return render(request, "login.html", ctx) return render(request, "login.html", ctx)
@login_required @login_required
def redirect_to_search(request): def search_page(request):
return redirect("search") # 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 results = []
def search_view(request): count = 0
total = Entry.objects.count() idx = 0
query = request.GET.get("q", "").strip() if q:
selected = request.GET.getlist("fields") or list(SEARCHABLE_FIELDS.keys()) like = wildcard_to_like(q)
entries = [] term_list = terms(q)
results_count = 0 # Build Q across selected fields, ANDing each term
current_id = None 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: # If no query or no results, render search page
q = build_query(selected, query) total = Entry.objects.count()
entries = list(Entry.objects.filter(q).order_by("-date_added","-id").values_list("id", flat=True)) return render(request, "search.html", {
results_count = len(entries) "q": q, "selected": selected, "total": total
request.session["search_ids"] = entries })
request.session["search_index"] = 0
if entries:
current_id = entries[0]
return redirect("record_view", entry_id=current_id)
return render(request, "search.html", { return render(request, "search.html", {"selected": default_fields})
"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")
@login_required @login_required
def nav_next(request): def nav_next(request):
ids = request.session.get("search_ids", []) ids = request.session.get("result_ids", [])
idx = request.session.get("search_index", 0) if not ids:
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])
return redirect("search") 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 @login_required
@user_passes_test(is_admin) @user_passes_test(is_admin)
def import_wizard(request): def import_wizard(request):
from .forms import ImportForm from .utils import import_csv_bytes
if request.method == "POST": if request.method == "POST":
form = ImportForm(request.POST, request.FILES) form = ImportForm(request.POST, request.FILES)
if form.is_valid(): if form.is_valid():
fbytes = form.cleaned_data["file"].read() f = form.cleaned_data["file"].read()
dry = form.cleaned_data["dry_run"] dry = form.cleaned_data["dry_run"]
try: 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}) return render(request, "import_result.html", {"report": report, "dry_run": dry})
except Exception as e: except Exception as e:
messages.error(request, f"Import failed: {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") ts = now().strftime("%Y-%m-%d_%H-%M-%S")
response['Content-Disposition'] = f'attachment; filename="illustrations_backup_{ts}.csv"' response['Content-Disposition'] = f'attachment; filename="illustrations_backup_{ts}.csv"'
writer = csv.writer(response) 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"): for e in Entry.objects.all().order_by("id"):
writer.writerow([ writer.writerow([
e.subject, e.illustration, e.application, e.scripture_raw, e.source, 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_added.isoformat() if e.date_added else "",
e.date_edited.isoformat() if e.date_edited else "", e.date_edited.isoformat() if e.date_edited else "",
]) ])
return response 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
})

20
web/entrypoint.sh Normal file
View File

@ -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

View File

@ -1,4 +1,3 @@
import os import os
from pathlib import Path from pathlib import Path

View File

@ -1,4 +1,3 @@
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from django.contrib.auth import views as auth_views from django.contrib.auth import views as auth_views
@ -8,13 +7,14 @@ urlpatterns = [
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("login/", core_views.login_view, name="login"), path("login/", core_views.login_view, name="login"),
path("logout/", auth_views.LogoutView.as_view(), name="logout"), path("logout/", auth_views.LogoutView.as_view(), name="logout"),
path("", core_views.redirect_to_search), path("", core_views.search_page, name="home"),
path("search/", core_views.search_view, name="search"), path("search/", core_views.search_page, name="search"),
path("record/<int:entry_id>/", core_views.record_view, name="record_view"), path("entry/<int:entry_id>/", core_views.entry_view, name="entry_view"),
path("record/<int:entry_id>/save/", core_views.record_save, name="record_save"), path("entry/<int:entry_id>/edit/", core_views.entry_edit, name="entry_edit"),
path("record/<int:entry_id>/delete/", core_views.record_delete, name="record_delete"), path("entry/<int:entry_id>/delete/", core_views.entry_delete, name="entry_delete"),
path("nav/prev/", core_views.nav_prev, name="nav_prev"),
path("nav/next/", core_views.nav_next, name="nav_next"), 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("import/", core_views.import_wizard, name="import_wizard"),
path("export/csv/", core_views.export_csv, name="export_csv"), path("export/csv/", core_views.export_csv, name="export_csv"),
path("stats/", core_views.stats_page, name="stats"),
] ]

View File

@ -1,4 +1,3 @@
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'illustrations.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'illustrations.settings')

View File

@ -1,4 +1,3 @@
#!/usr/bin/env python #!/usr/bin/env python
import os, sys import os, sys
def main(): def main():

View File

@ -1,4 +1,3 @@
Django==5.0.6 Django==5.0.6
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0

View File

@ -1,4 +1,3 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -6,32 +5,39 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Illustrations DB{% endblock %}</title> <title>{% block title %}Illustrations DB{% endblock %}</title>
<style> <style>
:root {
--blue:#1f6cd8; :root { --blue:#1f6cd8; --light:#f6f8fb; --panel:#ffffff; --line:#e5e9f2; --text:#1a1a1a; }
--light:#f6f8fb; body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica, Arial, sans-serif; background:var(--light); color:var(--text); }
--panel:#ffffff; .topbar { display:flex; align-items:center; justify-content:space-between; padding:14px 18px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:10; }
--line:#e5e9f2; .brand { font-weight:700; color:var(--blue); font-size: 18px; }
--text:#1a1a1a; .menu { display:flex; align-items:center; gap:8px; }
--muted:#6b7280; .menu a { text-decoration:none; color:var(--blue); padding:6px 10px; border-radius:6px; border:1px solid transparent; }
} .menu a:hover { background:#eef4ff; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica, Arial, sans-serif; background:var(--light); color:var(--text); } .container { max-width: 1100px; margin: 24px auto; padding: 0 16px; }
.topbar { display:flex; align-items:center; justify-content:space-between; padding:12px 16px; background:#fff; border-bottom:1px solid var(--line); position:sticky; top:0; z-index:10;} .panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; box-shadow: 0 8px 20px rgba(0,0,0,0.04); padding: 18px; }
.brand { font-weight:700; color:var(--blue); letter-spacing:.2px; } .btn { display:inline-block; padding:10px 14px; border-radius:8px; border:1px solid var(--line); background:#fff; color:#0d1b2a; text-decoration:none; cursor:pointer; }
.menu a { margin-left:14px; text-decoration:none; color:var(--blue); } .btn.primary { background:var(--blue); color:#fff; border-color:var(--blue); }
.container { max-width: 1100px; margin: 24px auto; padding: 0 16px; } .btn.danger { background:#e11d48; color:#fff; border-color:#e11d48; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:12px; box-shadow: 0 8px 20px rgba(0,0,0,0.04); padding: 18px; } .flash { margin: 16px 0; padding: 12px; border-radius:10px; background:#eaf2ff; color:#0b3d91; }
.btn { display:inline-block; padding:10px 14px; border-radius:10px; border:1px solid var(--line); background:#fff; color:#0d1b2a; text-decoration:none; cursor:pointer; } input[type=file], input[type=text], input[type=password], textarea, select { width:100%; padding:10px; border:1px solid var(--line); border-radius:10px; background:#fff; }
.btn.primary { background:var(--blue); color:#fff; border-color:var(--blue); } label { font-size:14px; color:#333; display:block; margin-bottom:6px; }
.btn.danger { background:#d61f1f; color:#fff; border-color:#d61f1f; } .row { display:grid; grid-template-columns: 1fr 1fr; gap:16px; }
.flash { margin: 16px 0; padding: 12px; border-radius:10px; background:#eaf2ff; color:#0b3d91; } @media (max-width: 800px){ .row{ grid-template-columns: 1fr; } }
input[type=text], input[type=password], input[type=date], textarea { width:100%; padding:10px; border:1px solid var(--line); border-radius:10px; background:#fff; } .cards { display:grid; grid-template-columns: repeat(3, 1fr); gap:16px; }
label { font-size: 13px; color:#333; display:block; margin-bottom:6px; } @media (max-width: 1000px){ .cards{ grid-template-columns: repeat(2, 1fr);} }
.row { display:grid; grid-template-columns: 1fr 1fr; gap:16px; } @media (max-width: 640px){ .cards{ grid-template-columns: 1fr;} }
.row3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:16px; } .card { background:#fff; border:1px solid var(--line); border-radius:12px; padding:16px; box-shadow: 0 6px 14px rgba(0,0,0,0.03); }
.muted { color: var(--muted); } .card:hover { box-shadow: 0 10px 24px rgba(0,0,0,0.05); transform: translateY(-1px); transition: all .15s ease; }
@media (max-width: 900px){ .row, .row3{ grid-template-columns: 1fr; } } .stat { padding:16px; text-align:center; border:1px solid var(--line); border-radius:12px; background:#fff; }
.stats { display:flex; gap:12px; align-items:center; font-size:14px; color:var(--muted); } .badge { display:inline-block; padding:2px 8px; border-radius:6px; border:1px solid var(--line); background:#f7faff; color:#1f4bb6; font-size:12px; }
.nav { display:flex; gap:8px; align-items:center; } .chips { display:flex; flex-wrap:wrap; gap:6px; }
.chip { padding:2px 8px; border-radius:6px; border:1px solid var(--line); background:#f1f5ff; color:#1a45a0; font-size:12px; }
.toolbar { display:flex; align-items:center; gap:8px; justify-content:space-between; margin-bottom:12px; flex-wrap:wrap; }
.spacer { height:8px; }
.small { font-size:12px; color:#444; }
h1 { margin:0 0 12px 0; font-size:24px; }
h2 { margin:0 0 12px 0; font-size:18px; }
</style> </style>
</head> </head>
<body> <body>
@ -40,16 +46,17 @@
<div class="brand">Illustrations Database</div> <div class="brand">Illustrations Database</div>
<div class="menu"> <div class="menu">
<a href="/search/">Search</a> <a href="/search/">Search</a>
{% if request.user.is_staff %} <a href="/import/">Import</a>
<a href="/import/">Import Data</a> <a href="/export/csv/">Backup</a>
<a href="/export/csv/">Download Backup</a> <a href="/stats/">Statistics</a>
{% endif %}
<a href="/logout/">Logout</a> <a href="/logout/">Logout</a>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="container"> <div class="container">
{% for message in messages %}<div class="flash">{{ message }}</div>{% endfor %} {% for message in messages %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</body> </body>

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Delete Entry - Illustrations DB{% endblock %}
{% block content %}
<div class="panel">
<h2>Delete Entry</h2>
<p>Are you sure you want to permanently delete <strong>{{ entry.talk_title|default:'(untitled)' }}</strong> (Code: {{ entry.entry_code }})?</p>
<form method="post">{% csrf_token %}
<a class="btn" href="/entry/{{ entry.id }}/">Cancel</a>
<button class="btn danger" type="submit">Delete</button>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}Edit Entry - Illustrations DB{% endblock %}
{% block content %}
<div class="toolbar">
<div class="small">Editing: Record {{ position }} of {{ count }}</div>
<div>
<a class="btn" href="/entry/{{ entry.id }}/">Cancel</a>
</div>
</div>
<form method="post">{% csrf_token %}
<div class="panel">
<h2 style="margin-top:0;">Edit Entry</h2>
<div class="row">
<div>
<label>Talk Title</label>
{{ form.talk_title }}
</div>
<div>
<label>Talk Number</label>
{{ form.talk_number }}
</div>
</div>
<div class="row">
<div>
<label>Source</label>
{{ form.source }}
</div>
<div>
<label>Code</label>
{{ form.entry_code }}
</div>
</div>
<label>Subject</label>
{{ form.subject }}
<label>Illustration</label>
{{ form.illustration }}
<label>Application</label>
{{ form.application }}
<div class="row">
<div>
<label>Scripture</label>
{{ form.scripture_raw }}
</div>
<div>
<label>Date Added</label>
{{ form.date_added }}
<label>Date Edited</label>
{{ form.date_edited }}
</div>
</div>
<div style="margin-top:16px; display:flex; gap:10px; justify-content:flex-end;">
<button class="btn primary" type="submit">Save Changes</button>
</div>
</div>
</form>
{% endblock %}

View File

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Entry - Illustrations DB{% endblock %}
{% block content %}
<div class="toolbar">
<div class="small">Viewing: Record {{ position }} of {{ count }}</div>
<div>
<a class="btn" href="/nav/prev/?i={{ position|add:-1 }}">← Prev</a>
<a class="btn" href="/nav/next/?i={{ position|add:-1 }}">Next →</a>
<a class="btn" href="/search/">Back to Search</a>
<a class="btn primary" href="/entry/{{ entry.id }}/edit/">Unlock to Edit</a>
<a class="btn danger" href="/entry/{{ entry.id }}/delete/">Delete</a>
</div>
</div>
<div class="panel">
<h2 style="margin-top:0;">{{ entry.talk_title|default:"(untitled)" }}</h2>
<div class="small">Code: {{ entry.entry_code }} • Source: {{ entry.source }} • Talk # {{ entry.talk_number }}</div>
<div class="spacer"></div>
<div class="row">
<div>
<label>Subject</label>
<div class="chips">
{% for t in entry.subject.split(',') %}{% if t.strip %}<span class="chip">{{ t.strip }}</span>{% endif %}{% endfor %}
</div>
</div>
<div>
<label>Scripture</label>
<div class="chips">
{% for t in entry.scripture_raw.split(';') %}{% if t.strip %}<span class="chip" style="background:#eef4ff;">{{ t.strip }}</span>{% endif %}{% endfor %}
</div>
</div>
</div>
<div class="spacer"></div>
<label>Illustration</label>
<div class="card">{{ entry.illustration|linebreaksbr }}</div>
<div class="spacer"></div>
<label>Application</label>
<div class="card">{{ entry.application|linebreaksbr }}</div>
<div class="spacer"></div>
<div class="small">Date Added: {{ entry.date_added }} • Date Edited: {{ entry.date_edited }}</div>
</div>
{% endblock %}

View File

@ -1,4 +1,3 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Import Result - Illustrations DB{% endblock %} {% block title %}Import Result - Illustrations DB{% endblock %}
{% block content %} {% block content %}
@ -14,7 +13,7 @@
</ul> </ul>
{% if report.errors and report.errors|length %} {% if report.errors and report.errors|length %}
<h3>Errors</h3> <h3>Errors</h3>
<pre style="white-space:pre-wrap;">{{ report.errors|join("\n") }}</pre> <pre>{{ report.errors|join("\n") }}</pre>
{% endif %} {% endif %}
<div style="margin-top:16px;"> <div style="margin-top:16px;">
<a class="btn" href="/import/">Run again</a> <a class="btn" href="/import/">Run again</a>

View File

@ -1,4 +1,3 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Import Data - Illustrations DB{% endblock %} {% block title %}Import Data - Illustrations DB{% endblock %}
{% block content %} {% block content %}

View File

@ -1,4 +1,3 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Sign in - Illustrations DB{% endblock %} {% block title %}Sign in - Illustrations DB{% endblock %}
{% block content %} {% block content %}

View File

@ -1,101 +0,0 @@
{% extends "base.html" %}
{% block title %}Record - Illustrations DB{% endblock %}
{% block content %}
<div class="stats" style="margin-bottom:8px;">
<div>Total: <strong>{{ total }}</strong></div>
<div>Results: <strong>{{ results_count }}</strong></div>
<div>Viewing: <strong>{{ position }}</strong> of <strong>{{ results_count|default:1 }}</strong></div>
</div>
<div class="panel">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div class="nav">
<a class="btn" href="/nav/prev/">&larr; Prev</a>
<a class="btn" href="/nav/next/">Next &rarr;</a>
</div>
<div>
<button class="btn" id="unlockBtn">Unlock to Edit</button>
<form method="post" action="/record/{{ entry.id }}/delete/" style="display:inline;" onsubmit="return confirm('Are you sure you want to permanently delete this entry?');">
{% csrf_token %}
<button class="btn danger">Delete</button>
</form>
</div>
</div>
<form id="entryForm" method="post" action="/record/{{ entry.id }}/save/" style="margin-top:14px;">
{% csrf_token %}
<div class="row">
<div>
<label>Subject</label>
<input type="text" name="subject" value="{{ entry.subject|default:'' }}" readonly />
</div>
<div>
<label>Scripture</label>
<input type="text" name="scripture_raw" value="{{ entry.scripture_raw|default:'' }}" readonly />
</div>
</div>
<div style="margin-top:12px;">
<label>Illustration</label>
<textarea name="illustration" rows="5" readonly>{{ entry.illustration|default:'' }}</textarea>
</div>
<div style="margin-top:12px;">
<label>Application</label>
<textarea name="application" rows="5" readonly>{{ entry.application|default:'' }}</textarea>
</div>
<div class="row3" style="margin-top:12px;">
<div>
<label>Source</label>
<input type="text" name="source" value="{{ entry.source|default:'' }}" readonly />
</div>
<div>
<label>Talk Number</label>
<input type="text" name="talk_number" value="{{ entry.talk_number|default:'' }}" readonly />
</div>
<div>
<label>Code</label>
<input type="text" name="entry_code" value="{{ entry.entry_code|default:'' }}" readonly />
</div>
</div>
<div class="row" style="margin-top:12px;">
<div>
<label>Talk Title</label>
<input type="text" name="talk_title" value="{{ entry.talk_title|default:'' }}" readonly />
</div>
<div class="row" style="grid-template-columns: 1fr 1fr; gap:12px;">
<div>
<label>Date</label>
<input type="text" name="date_added" value="{{ entry.date_added|default:'' }}" readonly />
</div>
<div>
<label>Date Edited</label>
<input type="text" name="date_edited" value="{{ entry.date_edited|default:'' }}" readonly />
</div>
</div>
</div>
<div style="margin-top:16px; display:flex; gap:10px; justify-content:flex-end;">
<a class="btn" href="/search/">Back to Search</a>
<button class="btn primary" id="saveBtn" type="submit" disabled>Save Changes</button>
</div>
</form>
</div>
<script>
const unlockBtn = document.getElementById('unlockBtn');
const form = document.getElementById('entryForm');
const saveBtn = document.getElementById('saveBtn');
unlockBtn.addEventListener('click', function(){
form.querySelectorAll('input, textarea').forEach(el=>{
if(el.name !== 'csrfmiddlewaretoken'){ el.removeAttribute('readonly'); }
});
saveBtn.disabled = false;
unlockBtn.disabled = true;
unlockBtn.textContent = 'Unlocked';
});
</script>
{% endblock %}

View File

@ -1,36 +1,41 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Search - Illustrations DB{% endblock %} {% block title %}Search - Illustrations DB{% endblock %}
{% block content %} {% block content %}
<div class="panel"> <div class="panel">
<h1>Search</h1>
<p class="small"><strong>How to search:</strong> Type words or phrases, use wildcards, and choose which fields to search.
<br/>Examples: <code>faith</code> finds entries containing “faith”; <code>*faith*</code> uses wildcards; <code>"exact phrase"</code> matches the phrase; multiple words are ANDed (e.g., <code>faith loyalty</code>).</p>
<form method="get" action="/search/"> <form method="get" action="/search/">
<div class="row"> <div class="row">
<div> <div>
<label>Search (supports * and ?)</label> <label>Search terms</label>
<input type="text" name="q" value="{{ q|default:'' }}" placeholder="e.g., Default, Organization or Matt 12:30 or *loyal*" /> <input type="text" name="q" value="{{ q|default:'' }}" placeholder="Type words, phrases, or wildcards…" />
</div> </div>
<div> <div>
<label>Fields to search</label> <label>Fields to search</label>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap:6px; padding-top:6px;"> <div class="chips">
{% for f in fields %} {% for field,label,checked in [
<label><input type="checkbox" name="fields" value="{{ f }}" {% if f in selected %}checked{% endif %}> {{ f }}</label> ('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),
] %}
<label class="badge">
<input type="checkbox" name="{{ field }}" {% if checked %}checked{% endif %}/> {{ label }}
</label>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<div style="margin-top:12px; display:flex; gap:10px; justify-content:flex-end;"> <div style="margin-top:16px; display:flex; gap:10px; justify-content:flex-end;">
<a class="btn" href="/search/">Clear</a>
<button class="btn primary" type="submit">Search</button> <button class="btn primary" type="submit">Search</button>
</div> </div>
</form> </form>
</div> </div>
<div class="stats" style="margin-top:12px;"> <div class="spacer"></div>
<div>Total entries: <strong>{{ total }}</strong></div> <div class="small">Total entries in database: <strong>{{ total }}</strong></div>
{% if results_count %}<div>Results: <strong>{{ results_count }}</strong></div>{% endif %}
</div>
{% if results_count %}
<div class="panel" style="margin-top:12px;">
<p>Opening first result…</p>
</div>
{% endif %}
{% endblock %} {% endblock %}

39
web/templates/stats.html Normal file
View File

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}Statistics - Illustrations DB{% endblock %}
{% block content %}
<div class="panel">
<h1>Statistics</h1>
<div class="row">
<div class="stat"><div class="small">Total entries</div><div style="font-size:28px; font-weight:700;">{{ total }}</div></div>
<div class="stat"><div class="small">New in last 30 days</div><div style="font-size:28px; font-weight:700;">{{ last30 }}</div></div>
<div class="stat"><div class="small">New in last 365 days</div><div style="font-size:28px; font-weight:700;">{{ last365 }}</div></div>
</div>
<div class="spacer"></div>
<h2>Trend (last 12 months)</h2>
<div class="card">
<div class="small">Entries per month (by Date Added)</div>
<div style="display:flex; gap:6px; align-items:flex-end; height:120px; margin-top:8px;">
{% with maxv=series|last|slice:":1" %}{% endwith %}
{% with peak=series|map:'1' %}{% endwith %}
{% for label, value in series %}
{% with h=(value|add:0) %}
<div title="{{ label }}: {{ value }}" style="width:24px; background:#dbe7ff; border:1px solid #c8d6ff; height: {{ (value|floatformat:0) }}px;"></div>
{% endwith %}
{% endfor %}
</div>
<div class="small" style="display:flex; gap:8px; flex-wrap:wrap; margin-top:6px;">
{% for label, value in series %}<span>{{ label }}</span>{% endfor %}
</div>
</div>
<div class="spacer"></div>
<h2>Top Subjects</h2>
<div class="cards">
{% for item in top_subjects %}
<div class="card">
<div style="font-weight:600;">{{ item.name }}</div>
<div class="small">{{ item.count }} entries</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}