From 38755968cdd2ed66cdded3238e4b941c421d2728 Mon Sep 17 00:00:00 2001 From: Joshua Laymon Date: Wed, 13 Aug 2025 05:12:37 +0000 Subject: [PATCH] Update web/core/views.py --- web/core/views.py | 381 +++++++++++++++++++++++++++++++++------------- 1 file changed, 275 insertions(+), 106 deletions(-) diff --git a/web/core/views.py b/web/core/views.py index 6a20968..b5cf283 100644 --- a/web/core/views.py +++ b/web/core/views.py @@ -6,177 +6,346 @@ from django.contrib import messages from django.db.models import Q from datetime import date, timedelta import csv + from .models import Entry 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=[ - ("subject","Subject"), - ("illustration","Illustration"), - ("application","Application"), - ("scripture_raw","Scripture"), - ("source","Source"), - ("talk_title","Talk Title"), - ("talk_number","Talk Number"), - ("entry_code","Code"), +# Order + labels used in the Search UI +FIELD_ORDER = [ + ("subject", "Subject"), + ("illustration", "Illustration"), + ("application", "Application"), + ("scripture_raw", "Scripture"), + ("source", "Source"), + ("talk_title", "Talk Title"), + ("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): - if request.user.is_authenticated: return redirect("search") - ctx={} - if request.method=="POST": - u=request.POST.get("username"); p=request.POST.get("password") - user=authenticate(request, username=u, password=p) - if user: login(request,user); return redirect("search") - ctx["error"]="Invalid credentials" - return render(request,"login.html",ctx) + if request.user.is_authenticated: + return redirect("search") + ctx = {} + if request.method == "POST": + u = request.POST.get("username") + p = request.POST.get("password") + user = authenticate(request, username=u, password=p) + if user: + login(request, user) + return redirect("search") + ctx["error"] = "Invalid credentials" + return render(request, "login.html", ctx) + def entry_context(entry, result_ids): + """ + Build the navigation + chips context for the entry pages. + """ count = len(result_ids or []) if entry and result_ids and entry.id in result_ids: position = result_ids.index(entry.id) + 1 else: 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()] - return {"entry":entry,"locked":True,"position":position,"count":count, - "subject_list":subject_list,"scripture_list":scripture_list} + + 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() + ] + return { + "entry": entry, + "locked": True, + "position": position, + "count": count, + "subject_list": subject_list, + "scripture_list": scripture_list, + } + @login_required 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 form_submitted = ("q" in request.GET) or any(k in request.GET for k in default_fields) 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: - selected=default_fields.copy() - field_options=[{"name":k,"label":label,"checked":bool(selected.get(k))} for k,label in FIELD_ORDER] + selected = default_fields.copy() - 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: tokens = terms(q) # supports quoted phrases - fields = [f for f, sel in selected.items() if sel] - qs = Entry.objects.all() - # AND across tokens, OR across selected fields - for t in tokens: - clause = Q() - for f in fields: - clause |= Q(**{f + "__icontains": t}) - 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: - 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}) + fields = [f for f, sel in selected.items() if sel] or ["subject"] + + qs = Entry.objects.all() + + # Build filters: AND across tokens, each token ORs across fields + for tok in tokens: + token_clause = Q() + if has_wildcards(tok): + pattern = wildcard_to_regex(tok) + for f in fields: + token_clause |= Q(**{f + "__iregex": pattern}) + else: + for f in fields: + token_clause |= Q(**{f + "__icontains": tok}) + qs = qs.filter(token_clause) + + 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 def nav_next(request): - ids=request.session.get("result_ids",[]) - if not ids: return redirect("search") - 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_context(entry, ids)) + ids = request.session.get("result_ids", []) + if not ids: + return redirect("search") + 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_context(entry, 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_context(entry, ids)) + 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_context(entry, ids)) + @login_required def entry_view(request, entry_id): - ids=request.session.get("result_ids",[]) - entry=get_object_or_404(Entry, pk=entry_id) - return render(request,"entry_view.html",entry_context(entry, ids)) + ids = request.session.get("result_ids", []) + entry = get_object_or_404(Entry, pk=entry_id) + return render(request, "entry_view.html", entry_context(entry, ids)) + @login_required def entry_edit(request, entry_id): - ids=request.session.get("result_ids",[]) - entry=get_object_or_404(Entry, pk=entry_id) - if request.method=="POST": - form=EntryForm(request.POST) + ids = request.session.get("result_ids", []) + 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) + 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: - 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}) - ctx={"entry":entry,"form":form}; ctx.update(entry_context(entry, ids)) - return render(request,"entry_edit.html",ctx) + 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, + } + ) + + ctx = {"entry": entry, "form": form} + ctx.update(entry_context(entry, ids)) + return render(request, "entry_edit.html", ctx) + @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}) + 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): - if request.method=="POST": - form=ImportForm(request.POST, request.FILES) + if request.method == "POST": + form = ImportForm(request.POST, request.FILES) if form.is_valid(): try: - report=import_csv_bytes(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"]}) + report = import_csv_bytes( + 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: messages.error(request, f"Import failed: {e}") else: - form=ImportForm() - return render(request,"import_wizard.html",{"form":form}) + form = ImportForm() + return render(request, "import_wizard.html", {"form": form}) + @login_required @user_passes_test(is_admin) def export_csv(request): - ts=date.today().strftime("%Y-%m-%d") - response=HttpResponse(content_type='text/csv') - response['Content-Disposition']=f'attachment; filename="illustrations_backup_{ts}.csv"' - w=csv.writer(response) - w.writerow(["Subject","Illustration","Application","Scripture","Source","Talk Number","Talk Title","Code","Date","Date Edited"]) + ts = date.today().strftime("%Y-%m-%d") + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = ( + f'attachment; filename="illustrations_backup_{ts}.csv"' + ) + 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"): - w.writerow([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 ""]) + w.writerow( + [ + 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 + @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() + 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() + # last 12 months - months=[]; y=today.year; m=today.month + months = [] + y = today.year + m = today.month for i in range(12): - mm=m-i; yy=y - while mm<=0: mm+=12; yy-=1 + mm = m - i + yy = y + while mm <= 0: + mm += 12 + yy -= 1 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) - label=f"{yy}-{mm:02d}" - months.append((label,start,end)) - months=list(reversed(months)) - series=[(label, Entry.objects.filter(date_added__gte=start, date_added__lt=end).count()) for label,start,end in months] + + start = _d(yy, mm, 1) + end = _d(yy + 1, 1, 1) if mm == 12 else _d(yy, mm + 1, 1) + label = f"{yy}-{mm:02d}" + months.append((label, start, end)) + 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) - 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 from collections import Counter - counts=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,"heights":heights,"top_subjects":top_subjects}) + 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, + "heights": heights, + "top_subjects": top_subjects, + }, + ) \ No newline at end of file