Update web/core/views.py

This commit is contained in:
Joshua Laymon 2025-08-13 05:12:37 +00:00
parent e67834a5e8
commit 38755968cd

View File

@ -6,177 +6,346 @@ from django.contrib import messages
from django.db.models import Q from django.db.models import Q
from datetime import date, timedelta from datetime import date, timedelta
import csv import csv
from .models import Entry from .models import Entry
from .forms import ImportForm, EntryForm from .forms import ImportForm, EntryForm
from .utils import import_csv_bytes, terms from .utils import import_csv_bytes, terms, has_wildcards, wildcard_to_regex
FIELD_ORDER=[ # Order + labels used in the Search UI
("subject","Subject"), FIELD_ORDER = [
("illustration","Illustration"), ("subject", "Subject"),
("application","Application"), ("illustration", "Illustration"),
("scripture_raw","Scripture"), ("application", "Application"),
("source","Source"), ("scripture_raw", "Scripture"),
("talk_title","Talk Title"), ("source", "Source"),
("talk_number","Talk Number"), ("talk_title", "Talk Title"),
("entry_code","Code"), ("talk_number", "Talk Number"),
("entry_code", "Code"),
] ]
def is_admin(user): return user.is_superuser or user.is_staff
def is_admin(user):
return user.is_superuser or user.is_staff
def login_view(request): def login_view(request):
if request.user.is_authenticated: return redirect("search") if request.user.is_authenticated:
ctx={} return redirect("search")
if request.method=="POST": ctx = {}
u=request.POST.get("username"); p=request.POST.get("password") if request.method == "POST":
user=authenticate(request, username=u, password=p) u = request.POST.get("username")
if user: login(request,user); return redirect("search") p = request.POST.get("password")
ctx["error"]="Invalid credentials" user = authenticate(request, username=u, password=p)
return render(request,"login.html",ctx) if user:
login(request, user)
return redirect("search")
ctx["error"] = "Invalid credentials"
return render(request, "login.html", ctx)
def entry_context(entry, result_ids): def entry_context(entry, result_ids):
"""
Build the navigation + chips context for the entry pages.
"""
count = len(result_ids or []) count = len(result_ids or [])
if entry and result_ids and entry.id in result_ids: if entry and result_ids and entry.id in result_ids:
position = result_ids.index(entry.id) + 1 position = result_ids.index(entry.id) + 1
else: else:
position = 1 position = 1
subject_list = [t.strip() for t in (entry.subject or "").split(",") if t.strip()]
scripture_list = [t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()] subject_list = [t.strip() for t in (entry.subject or "").split(",") if t.strip()]
return {"entry":entry,"locked":True,"position":position,"count":count, scripture_list = [
"subject_list":subject_list,"scripture_list":scripture_list} t.strip() for t in (entry.scripture_raw or "").split(";") if t.strip()
]
return {
"entry": entry,
"locked": True,
"position": position,
"count": count,
"subject_list": subject_list,
"scripture_list": scripture_list,
}
@login_required @login_required
def search_page(request): def search_page(request):
default_fields={"subject":True,"illustration":True,"application":True, """
"scripture_raw":False,"source":False,"talk_title":False,"talk_number":False,"entry_code":False} Search-first landing. Defaults to Subject, Illustration, Application.
Supports:
- quoted phrases
- * and ? wildcards (translated to regex when present)
- AND across tokens, OR across the selected fields
"""
default_fields = {
"subject": True,
"illustration": True,
"application": True,
"scripture_raw": False,
"source": False,
"talk_title": False,
"talk_number": False,
"entry_code": False,
}
# robust checkbox parsing: present only if checked # robust checkbox parsing: present only if checked
form_submitted = ("q" in request.GET) or any(k in request.GET for k in default_fields) form_submitted = ("q" in request.GET) or any(k in request.GET for k in default_fields)
if form_submitted: if form_submitted:
selected={k:(k in request.GET) for k in default_fields} selected = {k: (k in request.GET) for k in default_fields}
else: else:
selected=default_fields.copy() selected = default_fields.copy()
field_options=[{"name":k,"label":label,"checked":bool(selected.get(k))} for k,label in FIELD_ORDER]
q=request.GET.get("q","").strip() field_options = [
{"name": k, "label": label, "checked": bool(selected.get(k))}
for k, label in FIELD_ORDER
]
q = (request.GET.get("q") or "").strip()
if q: if q:
tokens = terms(q) # supports quoted phrases tokens = terms(q) # supports quoted phrases
fields = [f for f, sel in selected.items() if sel] fields = [f for f, sel in selected.items() if sel] or ["subject"]
qs = Entry.objects.all()
# AND across tokens, OR across selected fields qs = Entry.objects.all()
for t in tokens:
clause = Q() # Build filters: AND across tokens, each token ORs across fields
for f in fields: for tok in tokens:
clause |= Q(**{f + "__icontains": t}) token_clause = Q()
qs = qs.filter(clause) if has_wildcards(tok):
ids = list(qs.order_by("-date_added","-id").values_list("id", flat=True)) pattern = wildcard_to_regex(tok)
request.session["result_ids"]=ids for f in fields:
count=len(ids) token_clause |= Q(**{f + "__iregex": pattern})
if count: else:
entry=Entry.objects.get(pk=ids[0]) for f in fields:
ctx=entry_context(entry, ids); ctx.update({"from_search":True}) token_clause |= Q(**{f + "__icontains": tok})
return render(request,"entry_view.html",ctx) qs = qs.filter(token_clause)
total=Entry.objects.count()
return render(request,"search.html",{"q":q,"selected":selected,"field_options":field_options,"total":total,"ran_search":True,"result_count":0}) ids = list(qs.order_by("-date_added", "-id").values_list("id", flat=True))
request.session["result_ids"] = ids
count = len(ids)
if count:
entry = Entry.objects.get(pk=ids[0])
ctx = entry_context(entry, ids)
ctx.update({"from_search": True})
return render(request, "entry_view.html", ctx)
total = Entry.objects.count()
return render(
request,
"search.html",
{
"q": q,
"selected": selected,
"field_options": field_options,
"total": total,
"ran_search": True,
"result_count": 0,
},
)
total = Entry.objects.count()
return render(
request,
"search.html",
{
"q": q,
"selected": selected,
"field_options": field_options,
"total": total,
"ran_search": False,
},
)
total=Entry.objects.count()
return render(request,"search.html",{"q":q,"selected":selected,"field_options":field_options,"total":total,"ran_search":False})
@login_required @login_required
def nav_next(request): def nav_next(request):
ids=request.session.get("result_ids",[]) ids = request.session.get("result_ids", [])
if not ids: return redirect("search") if not ids:
idx=int(request.GET.get("i","0")); idx=min(idx+1, len(ids)-1) return redirect("search")
entry=get_object_or_404(Entry, pk=ids[idx]) idx = int(request.GET.get("i", "0"))
return render(request,"entry_view.html",entry_context(entry, ids)) idx = min(idx + 1, len(ids) - 1)
entry = get_object_or_404(Entry, pk=ids[idx])
return render(request, "entry_view.html", entry_context(entry, ids))
@login_required @login_required
def nav_prev(request): def nav_prev(request):
ids=request.session.get("result_ids",[]) ids = request.session.get("result_ids", [])
if not ids: return redirect("search") if not ids:
idx=int(request.GET.get("i","0")); idx=max(idx-1, 0) return redirect("search")
entry=get_object_or_404(Entry, pk=ids[idx]) idx = int(request.GET.get("i", "0"))
return render(request,"entry_view.html",entry_context(entry, ids)) idx = max(idx - 1, 0)
entry = get_object_or_404(Entry, pk=ids[idx])
return render(request, "entry_view.html", entry_context(entry, ids))
@login_required @login_required
def entry_view(request, entry_id): def entry_view(request, entry_id):
ids=request.session.get("result_ids",[]) ids = request.session.get("result_ids", [])
entry=get_object_or_404(Entry, pk=entry_id) entry = get_object_or_404(Entry, pk=entry_id)
return render(request,"entry_view.html",entry_context(entry, ids)) return render(request, "entry_view.html", entry_context(entry, ids))
@login_required @login_required
def entry_edit(request, entry_id): def entry_edit(request, entry_id):
ids=request.session.get("result_ids",[]) ids = request.session.get("result_ids", [])
entry=get_object_or_404(Entry, pk=entry_id) entry = get_object_or_404(Entry, pk=entry_id)
if request.method=="POST":
form=EntryForm(request.POST) if request.method == "POST":
form = EntryForm(request.POST)
if form.is_valid(): if form.is_valid():
for k,v in form.cleaned_data.items(): setattr(entry,k,v) for k, v in form.cleaned_data.items():
entry.save(); messages.success(request,"Entry saved."); return redirect("entry_view", entry_id=entry.id) setattr(entry, k, v)
entry.save()
messages.success(request, "Entry saved.")
return redirect("entry_view", entry_id=entry.id)
else: else:
form=EntryForm(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(
ctx={"entry":entry,"form":form}; ctx.update(entry_context(entry, ids)) initial={
return render(request,"entry_edit.html",ctx) "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,
}
)
ctx = {"entry": entry, "form": form}
ctx.update(entry_context(entry, ids))
return render(request, "entry_edit.html", ctx)
@login_required @login_required
def entry_delete(request, entry_id): def entry_delete(request, entry_id):
entry=get_object_or_404(Entry, pk=entry_id) entry = get_object_or_404(Entry, pk=entry_id)
if request.method=="POST": if request.method == "POST":
entry.delete(); messages.success(request,"Entry deleted."); return redirect("search") entry.delete()
return render(request,"entry_delete_confirm.html",{"entry":entry}) 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):
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():
try: try:
report=import_csv_bytes(form.cleaned_data["file"].read(), dry_run=form.cleaned_data["dry_run"]) report = import_csv_bytes(
return render(request,"import_result.html",{"report":report,"dry_run":form.cleaned_data["dry_run"]}) form.cleaned_data["file"].read(),
dry_run=form.cleaned_data["dry_run"],
)
return render(
request,
"import_result.html",
{"report": report, "dry_run": form.cleaned_data["dry_run"]},
)
except Exception as e: except Exception as e:
messages.error(request, f"Import failed: {e}") messages.error(request, f"Import failed: {e}")
else: else:
form=ImportForm() form = ImportForm()
return render(request,"import_wizard.html",{"form":form}) return render(request, "import_wizard.html", {"form": form})
@login_required @login_required
@user_passes_test(is_admin) @user_passes_test(is_admin)
def export_csv(request): def export_csv(request):
ts=date.today().strftime("%Y-%m-%d") ts = date.today().strftime("%Y-%m-%d")
response=HttpResponse(content_type='text/csv') response = HttpResponse(content_type="text/csv")
response['Content-Disposition']=f'attachment; filename="illustrations_backup_{ts}.csv"' response["Content-Disposition"] = (
w=csv.writer(response) f'attachment; filename="illustrations_backup_{ts}.csv"'
w.writerow(["Subject","Illustration","Application","Scripture","Source","Talk Number","Talk Title","Code","Date","Date Edited"]) )
w = csv.writer(response)
w.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"):
w.writerow([e.subject,e.illustration,e.application,e.scripture_raw,e.source, w.writerow(
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 ""]) e.subject,
e.illustration,
e.application,
e.scripture_raw,
e.source,
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 return response
@login_required @login_required
def stats_page(request): def stats_page(request):
total=Entry.objects.count() total = Entry.objects.count()
today=date.today() today = date.today()
last30=Entry.objects.filter(date_added__gte=today - timedelta(days=30)).count() last30 = Entry.objects.filter(date_added__gte=today - timedelta(days=30)).count()
last365=Entry.objects.filter(date_added__gte=today - timedelta(days=365)).count() last365 = Entry.objects.filter(date_added__gte=today - timedelta(days=365)).count()
# last 12 months # last 12 months
months=[]; y=today.year; m=today.month months = []
y = today.year
m = today.month
for i in range(12): for i in range(12):
mm=m-i; yy=y mm = m - i
while mm<=0: mm+=12; yy-=1 yy = y
while mm <= 0:
mm += 12
yy -= 1
from datetime import date as _d from datetime import date as _d
start=_d(yy,mm,1)
end=_d(yy+1,1,1) if mm==12 else _d(yy,mm+1,1) start = _d(yy, mm, 1)
label=f"{yy}-{mm:02d}" end = _d(yy + 1, 1, 1) if mm == 12 else _d(yy, mm + 1, 1)
months.append((label,start,end)) label = f"{yy}-{mm:02d}"
months=list(reversed(months)) months.append((label, start, end))
series=[(label, Entry.objects.filter(date_added__gte=start, date_added__lt=end).count()) for label,start,end in months] months = list(reversed(months))
series = [
(label, Entry.objects.filter(date_added__gte=start, date_added__lt=end).count())
for label, start, end in months
]
peak = max((v for _, v in series), default=1) peak = max((v for _, v in series), default=1)
heights=[(label, value, 8 + int((value/peak)*100) if peak else 8) for label, value in series] heights = [
(label, value, 8 + int((value / peak) * 100) if peak else 8)
for label, value in series
]
# top subjects # top subjects
from collections import Counter from collections import Counter
counts=Counter()
counts = Counter()
for subj in Entry.objects.exclude(subject="").values_list("subject", flat=True): 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()]: for tag in [t.strip() for t in subj.split(",") if t.strip()]:
counts[tag.lower()]+=1 counts[tag.lower()] += 1
top_subjects=[{"name":n.title(),"count":c} for n,c in counts.most_common(10)] 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,"heights":heights,"top_subjects":top_subjects})
return render(
request,
"stats.html",
{
"total": total,
"last30": last30,
"last365": last365,
"series": series,
"heights": heights,
"top_subjects": top_subjects,
},
)