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
+2 -3
View File
@@ -1,4 +1,3 @@
from django.contrib import admin
from .models import Entry, ScriptureRef
@@ -8,6 +7,6 @@ class ScriptureInline(admin.TabularInline):
@admin.register(Entry)
class EntryAdmin(admin.ModelAdmin):
list_display = ("talk_title","entry_code","source","date_added","date_edited")
search_fields = ("talk_title","entry_code","source","subject","illustration","application","scripture_raw")
list_display = ("talk_title", "entry_code", "source", "date_added", "date_edited")
search_fields = ("talk_title", "entry_code", "source", "subject", "illustration", "application", "scripture_raw")
inlines = [ScriptureInline]
-1
View File
@@ -1,4 +1,3 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
+12 -1
View File
@@ -1,6 +1,17 @@
from django import forms
class ImportForm(forms.Form):
file = forms.FileField(allow_empty_file=False)
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"])
@@ -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)))
+13 -12
View File
@@ -1,27 +1,28 @@
from django.core.management.base import BaseCommand
from django.contrib.auth.models import User
import os
class Command(BaseCommand):
def handle(self, *args, **kwargs):
admin_user = os.getenv("INIT_ADMIN_USERNAME")
admin_pass = os.getenv("INIT_ADMIN_PASSWORD")
editor_user = os.getenv("INIT_EDITOR_USERNAME")
editor_pass = os.getenv("INIT_EDITOR_PASSWORD")
help = "Create or update initial users from environment variables."
def handle(self, *args, **options):
admin_user = os.getenv("ADMIN_USERNAME")
admin_pass = os.getenv("ADMIN_PASSWORD")
editor_user = os.getenv("USER_USERNAME")
editor_pass = os.getenv("USER_PASSWORD")
if admin_user and admin_pass:
u, created = User.objects.get_or_create(username=admin_user)
u.is_staff = True
u.is_superuser = True
if admin_pass:
u.set_password(admin_pass)
u.set_password(admin_pass)
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:
u, created = User.objects.get_or_create(username=editor_user)
u.is_staff = False
u.is_superuser = False
if editor_pass:
u.set_password(editor_pass)
u.set_password(editor_pass)
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}"))
+1 -2
View File
@@ -1,4 +1,3 @@
from django.db import migrations, models
import django.db.models.deletion
@@ -15,8 +14,8 @@ class Migration(migrations.Migration):
('application', models.TextField(blank=True)),
('scripture_raw', models.TextField(blank=True)),
('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_title', models.CharField(blank=True, max_length=255)),
('entry_code', models.CharField(blank=True, db_index=True, max_length=64)),
('date_added', models.DateField(blank=True, null=True)),
('date_edited', models.DateField(blank=True, null=True)),
+6 -8
View File
@@ -1,18 +1,16 @@
from django.db import models
class Entry(models.Model):
# Field names aligned to CSV headers (case-insensitive mapping in importer)
subject = models.TextField(blank=True)
illustration = 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)
talk_title = models.CharField(max_length=255, blank=True) # 'Talk Title'
talk_number = models.IntegerField(null=True, blank=True) # 'Talk Number'
entry_code = models.CharField(max_length=64, blank=True, db_index=True) # 'Code'
date_added = models.DateField(null=True, blank=True) # 'Date'
date_edited = models.DateField(null=True, blank=True) # 'Date Edited'
talk_number = models.IntegerField(null=True, blank=True)
talk_title = models.CharField(max_length=255, blank=True)
entry_code = models.CharField(max_length=64, blank=True, db_index=True)
date_added = models.DateField(null=True, blank=True)
date_edited = models.DateField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
+101 -97
View File
@@ -1,32 +1,32 @@
import csv, io, re
from datetime import datetime
import csv, io, re, calendar
from datetime import datetime, timedelta, date
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
# Scripture parsing --------------------------------------------------
BOOK_ALIASES = {
"gen":"Genesis","ge":"Genesis","ex":"Exodus","lev":"Leviticus","num":"Numbers","deut":"Deuteronomy",
"josh":"Joshua","judg":"Judges","rut":"Ruth","1sam":"1 Samuel","2sam":"2 Samuel",
"gen": "Genesis","ex": "Exodus","lev": "Leviticus","num": "Numbers","deut": "Deuteronomy",
"josh": "Joshua","judg":"Judges","rut":"Ruth","1sam":"1 Samuel","2sam":"2 Samuel",
"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",
"ezek":"Ezekiel","dan":"Daniel","hos":"Hosea","joel":"Joel","amos":"Amos","obad":"Obadiah",
"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",
"2cor":"2 Corinthians","gal":"Galatians","eph":"Ephesians","phil":"Philippians","col":"Colossians",
"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",
"1john":"1 John","2john":"2 John","3john":"3 John","jude":"Jude","rev":"Revelation","re":"Revelation",
"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",
}
SCR_REF_RE = re.compile(r"""
^\s*([1-3]?\s*[A-Za-z\.]+)\s+ # book
(\d+) # chapter start
(?::(\d+))? # verse start
(?:\s*[-–—]\s*(\d+)(?::(\d+))?)? # optional range
^\s*([1-3]?\s*[A-Za-z\.]+)\s+
(\d+)
(?::(\d+))?
(?:\s*[-–—]\s*(\d+)(?::(\d+))?)?
\s*$
""", re.VERBOSE)
@@ -35,12 +35,13 @@ def normalize_book(book_raw:str) -> 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))
+140 -115
View File
@@ -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
})