This commit is contained in:
Joshua Laymon
2025-08-12 22:07:25 -05:00
parent 2fb9e7c39c
commit 3458501272
24 changed files with 268 additions and 26444 deletions
Vendored
BIN
View File
Binary file not shown.
+3 -3
View File
@@ -3,9 +3,9 @@ services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
environment: environment:
POSTGRES_DB: illustrations POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: illustrations POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: illustrations POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: volumes:
- illustrations_db_data:/var/lib/postgresql/data - illustrations_db_data:/var/lib/postgresql/data
healthcheck: healthcheck:
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
Place your seed CSV here as illustrations_seed.csv with the proper headers. Place illustrations_seed.csv here.
File diff suppressed because one or more lines are too long
+2 -8
View File
@@ -1,16 +1,10 @@
FROM python:3.12-slim FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev postgresql-client && 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 RUN chmod +x /app/entrypoint.sh
CMD ["bash","-lc","/app/entrypoint.sh"]
CMD ["bash","-lc","/app/entrypoint.sh"]
+3 -5
View File
@@ -1,12 +1,10 @@
from django.contrib import admin from django.contrib import admin
from .models import Entry, ScriptureRef from .models import Entry, ScriptureRef
class ScriptureInline(admin.TabularInline): class ScriptureInline(admin.TabularInline):
model = ScriptureRef model = ScriptureRef
extra = 0 extra = 0
@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]
+2 -2
View File
@@ -1,4 +1,4 @@
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"
name = "core" name="core"
-2
View File
@@ -1,9 +1,7 @@
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): class EntryForm(forms.Form):
subject = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows":2})) subject = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows":2}))
illustration = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows":6})) illustration = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows":6}))
+5 -9
View File
@@ -1,17 +1,13 @@
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from pathlib import Path from pathlib import Path
from core.utils import import_csv_bytes from core.utils import import_csv_bytes
class Command(BaseCommand): class Command(BaseCommand):
help = "Import seed CSV (idempotent upsert by Code)." help="Import seed CSV (idempotent)"
def add_arguments(self,parser):
def add_arguments(self, parser):
parser.add_argument("path", nargs="?", default="/data/imports/illustrations_seed.csv") parser.add_argument("path", nargs="?", default="/data/imports/illustrations_seed.csv")
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
def handle(self, path, dry_run, *args, **kwargs): def handle(self, path, dry_run, *args, **kwargs):
p = Path(path) p=Path(path)
if not p.exists(): if not p.exists(): raise CommandError(f"CSV not found: {p}")
raise CommandError(f"CSV not found: {p}") report=import_csv_bytes(p.read_bytes(), dry_run=dry_run)
report = import_csv_bytes(p.read_bytes(), dry_run=dry_run)
self.stdout.write(self.style.SUCCESS(str(report))) self.stdout.write(self.style.SUCCESS(str(report)))
+12 -24
View File
@@ -1,28 +1,16 @@
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):
help = "Create or update initial users from environment variables." help="Ensure initial users from env"
def handle(self,*args,**kwargs):
def handle(self, *args, **options): au=os.getenv("ADMIN_USERNAME"); ap=os.getenv("ADMIN_PASSWORD")
admin_user = os.getenv("ADMIN_USERNAME") eu=os.getenv("USER_USERNAME"); ep=os.getenv("USER_PASSWORD")
admin_pass = os.getenv("ADMIN_PASSWORD") if au and ap:
editor_user = os.getenv("USER_USERNAME") u,_=User.objects.get_or_create(username=au)
editor_pass = os.getenv("USER_PASSWORD") u.is_staff=True; u.is_superuser=True; u.set_password(ap); u.save()
self.stdout.write(self.style.SUCCESS(f"Admin ensured: {au}"))
if admin_user and admin_pass: if eu and ep:
u, created = User.objects.get_or_create(username=admin_user) u,_=User.objects.get_or_create(username=eu)
u.is_staff = True u.is_staff=False; u.is_superuser=False; u.set_password(ep); u.save()
u.is_superuser = True self.stdout.write(self.style.SUCCESS(f"Editor ensured: {eu}"))
u.set_password(admin_pass)
u.save()
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
u.set_password(editor_pass)
u.save()
self.stdout.write(self.style.SUCCESS(f"Editor user ensured: {editor_user}"))
+3 -4
View File
@@ -1,10 +1,9 @@
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial=True
dependencies = [] dependencies=[]
operations = [ operations=[
migrations.CreateModel( migrations.CreateModel(
name='Entry', name='Entry',
fields=[ fields=[
+2 -9
View File
@@ -1,5 +1,4 @@
from django.db import models from django.db import models
class Entry(models.Model): class Entry(models.Model):
subject = models.TextField(blank=True) subject = models.TextField(blank=True)
illustration = models.TextField(blank=True) illustration = models.TextField(blank=True)
@@ -11,13 +10,9 @@ class Entry(models.Model):
entry_code = models.CharField(max_length=64, blank=True, db_index=True) entry_code = models.CharField(max_length=64, blank=True, db_index=True)
date_added = models.DateField(null=True, blank=True) date_added = models.DateField(null=True, blank=True)
date_edited = models.DateField(null=True, blank=True) 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)
def __str__(self): return f"{self.talk_title or '(untitled)'} [{self.entry_code}]"
def __str__(self):
return f"{self.talk_title or '(untitled)'} [{self.entry_code}]"
class ScriptureRef(models.Model): class ScriptureRef(models.Model):
entry = models.ForeignKey(Entry, on_delete=models.CASCADE, related_name="scripture_refs") entry = models.ForeignKey(Entry, on_delete=models.CASCADE, related_name="scripture_refs")
book = models.CharField(max_length=32) book = models.CharField(max_length=32)
@@ -25,6 +20,4 @@ class ScriptureRef(models.Model):
verse_from = models.IntegerField(null=True, blank=True) verse_from = models.IntegerField(null=True, blank=True)
chapter_to = models.IntegerField(null=True, blank=True) chapter_to = models.IntegerField(null=True, blank=True)
verse_to = models.IntegerField(null=True, blank=True) verse_to = models.IntegerField(null=True, blank=True)
def __str__(self): return f"{self.book} {self.chapter_from}:{self.verse_from}"
def __str__(self):
return f"{self.book} {self.chapter_from}:{self.verse_from}"
+60 -135
View File
@@ -1,167 +1,92 @@
import csv, io, re, calendar import csv, io, re
from datetime import datetime, timedelta, date
from dateutil import parser as dateparser from dateutil import parser as dateparser
from collections import Counter, defaultdict from datetime import date, timedelta
from django.db.models.functions import TruncMonth
from django.db.models import Count
from .models import Entry, ScriptureRef from .models import Entry, ScriptureRef
BOOK_ALIASES = { SCR_REF_RE = re.compile(r"""^\s*([1-3]?\s*[A-Za-z\.]+)\s+(\d+)(?::(\d+))?(?:\s*[-–—]\s*(\d+)(?::(\d+))?)?\s*$""", re.VERBOSE)
"gen": "Genesis","ex": "Exodus","lev": "Leviticus","num": "Numbers","deut": "Deuteronomy", BOOK_ALIASES={'matt':'Matthew','mt':'Matthew','jn':'John','john':'John','lk':'Luke','luke':'Luke','ps':'Psalms'}
"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","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","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","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""" def normalize_book(s):
^\s*([1-3]?\s*[A-Za-z\.]+)\s+ b = re.sub(r"[\.\s]","", s).lower()
(\d+) return BOOK_ALIASES.get(b, s.strip())
(?::(\d+))?
(?:\s*[-–—]\s*(\d+)(?::(\d+))?)?
\s*$
""", re.VERBOSE)
def normalize_book(book_raw:str) -> str: def parse_scripture(s):
b = re.sub(r"[\.\s]","", book_raw).lower() items=[];
return BOOK_ALIASES.get(b, book_raw.strip()) for p in [x.strip() for x in (s or '').split(';') if x.strip()]:
def parse_scripture(s: str):
parts = [p.strip() for p in s.split(";") if p.strip()]
parsed = []
for p in parts:
m = SCR_REF_RE.match(p) m = SCR_REF_RE.match(p)
if not m: if not m: items.append(None); continue
parsed.append(None) br, ch1, v1, ch2, v2 = m.groups()
continue items.append({"book": normalize_book(br), "chapter_from": int(ch1), "verse_from": int(v1) if v1 else None,
book_raw, ch1, v1, ch2, v2 = m.groups() "chapter_to": int(ch2) if ch2 else None, "verse_to": int(v2) if v2 else None})
parsed.append({ return items
"book": normalize_book(book_raw),
"chapter_from": int(ch1),
"verse_from": int(v1) if v1 else None,
"chapter_to": int(ch2) if ch2 else None,
"verse_to": int(v2) if v2 else None,
})
return parsed
def parse_date(value): def parse_date(v):
if not value or not str(value).strip(): if not v or not str(v).strip(): return None
return None try: return dateparser.parse(str(v)).date()
try: except Exception: return None
d = dateparser.parse(str(value)).date()
return d
except Exception:
return None
EXPECTED_HEADERS = [ EXPECTED_HEADERS=[h.lower() for h in ["Subject","Illustration","Application","Scripture","Source","Talk Title","Talk Number","Code","Date","Date Edited"]]
"subject","illustration","application","scripture","source","talk number",
"talk title","code","date","date edited"
]
def import_csv_bytes(file_bytes: bytes, dry_run: bool=True): def import_csv_bytes(b: bytes, dry_run=True):
text = file_bytes.decode("utf-8-sig") text = b.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text)) reader = csv.DictReader(io.StringIO(text))
headers = [h.strip().lower() for h in reader.fieldnames or []] headers=[(h or '').strip().lower() for h in (reader.fieldnames or [])]
missing = [h for h in EXPECTED_HEADERS if h not in headers] missing=[h for h in EXPECTED_HEADERS if h not in headers]
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)
report["rows"] = len(rows)
for row in rows: for row in rows:
try: try:
entry_code = (row.get("code") or "").strip() code=(row.get("code") or "").strip()
talk_number = row.get("talk number") talk=row.get("talk number");
try: try: talk=int(talk) if str(talk).strip() else None
talk_number = int(talk_number) if str(talk_number).strip() else None except: talk=None
except Exception: data=dict(
talk_number = None
date_added = parse_date(row.get("date"))
date_edited = parse_date(row.get("date edited"))
data = dict(
subject=row.get("subject") or "", subject=row.get("subject") or "",
illustration=row.get("illustration") or "", illustration=row.get("illustration") or "",
application=row.get("application") or "", application=row.get("application") or "",
scripture_raw=row.get("scripture") or "", scripture_raw=row.get("scripture") or "",
source=row.get("source") or "", source=row.get("source") or "",
talk_number=talk_number, talk_number=talk,
talk_title=row.get("talk title") or "", talk_title=row.get("talk title") or "",
entry_code=entry_code, entry_code=code,
date_added=date_added, date_added=parse_date(row.get("date")),
date_edited=date_edited, date_edited=parse_date(row.get("date edited")),
) )
parsed=parse_scripture(data["scripture_raw"])
from .models import Entry, ScriptureRef for it in parsed:
obj = None if it: report["scripture_parsed"]+=1
if entry_code: else: report["scripture_failed"]+=1
try: 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 code:
try:
obj=Entry.objects.get(entry_code=code)
except Entry.DoesNotExist:
obj=None
if obj: if obj:
for k, v in data.items(): for k,v in data.items(): setattr(obj,k,v)
setattr(obj, k, v) obj.save(); obj.scripture_refs.all().delete(); report["updated"]+=1
obj.save()
obj.scripture_refs.all().delete()
report["updated"] += 1
else: else:
from .models import Entry obj=Entry.objects.create(**data); report["inserted"]+=1
obj = Entry.objects.create(**data) from .models import ScriptureRef
report["inserted"] += 1 for it in parsed:
if it: ScriptureRef.objects.create(entry=obj, **it)
# persist parsed scripture refs
for item in parsed_list:
if item:
ScriptureRef.objects.create(entry=obj, **item)
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
def wildcard_to_like(q: str) -> str: def wildcard_to_like(q:str)->str:
# Convert * and ? to SQL LIKE wildcards
return q.replace("%","\%").replace("_","\_").replace("*","%").replace("?","_") return q.replace("%","\%").replace("_","\_").replace("*","%").replace("?","_")
def terms(q: str): def terms(q:str): return [t for t in q.split() if t.strip()]
return [t for t in q.split() if t.strip()]
def month_buckets_last_12(today: date): def month_buckets_last_12(today: date):
# returns list of (YYYY-MM, start, end) months=[]; y=today.year; m=today.month
months = []
y, m = today.year, today.month
for i in range(12): for i in range(12):
mm = m - i mm=m-i; yy=y
yy = y while mm<=0: mm+=12; yy-=1
while mm <= 0: start=date(yy,mm,1)
mm += 12 end=date(yy+1,1,1) if mm==12 else date(yy,mm+1,1)
yy -= 1
start = date(yy, mm, 1)
if mm == 12:
end = date(yy+1, 1, 1)
else:
end = date(yy, mm+1, 1)
months.append((f"{yy}-{mm:02d}", start, end)) months.append((f"{yy}-{mm:02d}", start, end))
return list(reversed(months)) return list(reversed(months))
+93 -144
View File
@@ -1,201 +1,150 @@
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, HttpResponseRedirect from django.http import HttpResponse
from django.contrib import messages from django.contrib import messages
from django.db.models import Q, Count from django.db.models import Q
from django.utils.timezone import now
from datetime import date, timedelta from datetime import date, timedelta
import csv, io 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, wildcard_to_like, terms, month_buckets_last_12 from .utils import import_csv_bytes, wildcard_to_like, terms, month_buckets_last_12
def is_admin(user): FIELD_ORDER=[
return user.is_superuser or user.is_staff ("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 login_view(request): def login_view(request):
if request.user.is_authenticated: if request.user.is_authenticated: return redirect("search")
return redirect("search") ctx={}
ctx = {} if request.method=="POST":
if request.method == "POST": u=request.POST.get("username"); p=request.POST.get("password")
username = request.POST.get("username") user=authenticate(request, username=u, password=p)
password = request.POST.get("password") if user: login(request,user); return redirect("search")
user = authenticate(request, username=username, password=password) ctx["error"]="Invalid credentials"
if user: return render(request,"login.html",ctx)
login(request, user)
return redirect("search")
else:
ctx["error"] = "Invalid credentials"
return render(request, "login.html", ctx)
@login_required @login_required
def search_page(request): def search_page(request):
# Defaults: Subject, Illustration, Application checked default_fields={"subject":True,"illustration":True,"application":True,
default_fields = {"subject": True, "illustration": True, "application": True, "scripture_raw":False,"source":False,"talk_title":False,"talk_number":False,"entry_code":False}
"scripture_raw": False, "source": False, "talk_title": False, if request.method=="GET":
"talk_number": False, "entry_code": False} selected={k:(request.GET.get(k,"on" if v else "")!="") for k,v in default_fields.items()}
if request.method == "GET": field_options=[{"name":k,"label":label,"checked":bool(selected.get(k))} for k,label in FIELD_ORDER]
# read selection from query or use defaults q=request.GET.get("q","").strip()
selected = {k: (request.GET.get(k,"on" if v else "") != "") for k,v in default_fields.items()}
q = request.GET.get("q","").strip()
results = []
count = 0
idx = 0
if q: if q:
like = wildcard_to_like(q) # AND across terms, OR across selected fields per term
term_list = terms(q) term_list=terms(q)
# Build Q across selected fields, ANDing each term qs=Entry.objects.all()
fields = [f for f,sel in selected.items() if sel] fields=[f for f,sel in selected.items() if sel]
qs = Entry.objects.all()
for t in term_list: for t in term_list:
pattern = wildcard_to_like(t) clause=Q()
clause = Q()
for f in fields: for f in fields:
clause |= Q(**{f+"__icontains": t.replace("*","").replace("?","")}) clause |= Q(**{f+"__icontains": t.replace("*","").replace("?","")})
qs = qs.filter(clause) qs=qs.filter(clause)
ids = list(qs.order_by("-date_added","-id").values_list("id", flat=True)) ids=list(qs.order_by("-date_added","-id").values_list("id", flat=True))
request.session["result_ids"] = ids request.session["result_ids"]=ids
count = len(ids) if ids:
if count: entry=Entry.objects.get(pk=ids[0])
idx = 0 return render(request,"entry_view.html",{"entry":entry,"locked":True,"position":1,"count":len(ids),"from_search":True})
entry = Entry.objects.get(pk=ids[idx]) total=Entry.objects.count()
return render(request, "entry_view.html", { return render(request,"search.html",{"q":q,"selected":selected,"field_options":field_options,"total":total})
"entry": entry, "locked": True, return render(request,"search.html",{"selected":default_fields,"field_options":[{"name":k,"label":lbl,"checked":default_fields.get(k,False)} for k,lbl in FIELD_ORDER]})
"position": idx+1, "count": count, "from_search": True
})
# 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", {"selected": default_fields})
@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: if not ids: return redirect("search")
return redirect("search") idx=int(request.GET.get("i","0")); idx=min(idx+1, len(ids)-1)
idx = int(request.GET.get("i","0")) entry=get_object_or_404(Entry, pk=ids[idx])
idx = min(idx+1, len(ids)-1) return render(request,"entry_view.html",{"entry":entry,"locked":True,"position":idx+1,"count":len(ids)})
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 @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: if not ids: return redirect("search")
return redirect("search") idx=int(request.GET.get("i","0")); idx=max(idx-1, 0)
idx = int(request.GET.get("i","0")) entry=get_object_or_404(Entry, pk=ids[idx])
idx = max(idx-1, 0) return render(request,"entry_view.html",{"entry":entry,"locked":True,"position":idx+1,"count":len(ids)})
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 @login_required
def entry_view(request, entry_id): def entry_view(request, entry_id):
entry = get_object_or_404(Entry, pk=entry_id) entry=get_object_or_404(Entry, pk=entry_id)
ids = request.session.get("result_ids", []) ids=request.session.get("result_ids",[]); count=len(ids)
count = len(ids) position=ids.index(entry.id)+1 if entry.id in ids else 1
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})
return render(request, "entry_view.html", {"entry": entry, "locked": True, "position": position, "count": count})
@login_required @login_required
def entry_edit(request, entry_id): def entry_edit(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":
form = EntryForm(request.POST) form=EntryForm(request.POST)
if form.is_valid(): if form.is_valid():
for k,v in form.cleaned_data.items(): for k,v in form.cleaned_data.items(): setattr(entry,k,v)
setattr(entry, k, v) entry.save(); messages.success(request,"Entry saved."); return redirect("entry_view", entry_id=entry.id)
entry.save()
messages.success(request, "Entry saved.")
return redirect("entry_view", entry_id=entry.id)
else: else:
initial = { "subject": entry.subject, "illustration": entry.illustration, "application": entry.application, 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})
"scripture_raw": entry.scripture_raw, "source": entry.source, "talk_number": entry.talk_number, ids=request.session.get("result_ids",[]); count=len(ids); position=ids.index(entry.id)+1 if entry.id in ids else 1
"talk_title": entry.talk_title, "entry_code": entry.entry_code, return render(request,"entry_edit.html",{"entry":entry,"form":form,"position":position,"count":count})
"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 @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() entry.delete(); messages.success(request,"Entry deleted."); return redirect("search")
messages.success(request, "Entry deleted.") return render(request,"entry_delete_confirm.html",{"entry":entry})
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 .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():
f = form.cleaned_data["file"].read()
dry = form.cleaned_data["dry_run"]
try: try:
report = import_csv_bytes(f, dry_run=dry) 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": dry}) 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):
response = HttpResponse(content_type='text/csv') ts=date.today().strftime("%Y-%m-%d")
ts = now().strftime("%Y-%m-%d_%H-%M-%S") response=HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="illustrations_backup_{ts}.csv"' response['Content-Disposition']=f'attachment; filename="illustrations_backup_{ts}.csv"'
writer = csv.writer(response) w=csv.writer(response)
writer.writerow(["Subject","Illustration","Application","Scripture","Source","Talk Number","Talk Title","Code","Date","Date Edited"]) 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"):
writer.writerow([ w.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_number if e.talk_number is not None else "", e.talk_title, e.entry_code,
e.talk_number if e.talk_number is not None else "", e.date_added.isoformat() if e.date_added else "", e.date_edited.isoformat() if e.date_edited 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()
buckets=month_buckets_last_12(today)
# Sparkline: counts per month for last 12 months (by date_added, blanks excluded) series=[(label, Entry.objects.filter(date_added__gte=start, date_added__lt=end).count()) for label,start,end in buckets]
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 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,"top_subjects":top_subjects})
return render(request, "stats.html", {
"total": total,
"last30": last30,
"last365": last365,
"series": series,
"top_subjects": top_subjects
})
+3 -9
View File
@@ -1,20 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -e set -e
echo "Waiting for Postgres..."
echo "Waiting for Postgres to be ready..." until pg_isready -h "${POSTGRES_HOST:-db}" -p "${POSTGRES_PORT:-5432}" -U "${POSTGRES_USER:-illustrations}" >/dev/null 2>&1; do
until pg_isready -h db -p 5432 -U "${POSTGRES_USER:-illustrations}" >/dev/null 2>&1; do
sleep 1 sleep 1
done done
echo "Postgres is ready."
python manage.py migrate python manage.py migrate
python manage.py init_users python manage.py init_users
if [ "${IMPORT_SEED_ON_START}" = "true" ] && [ -f "${SEED_CSV}" ] && [ ! -f /data/imports/.seed_done ]; then if [ "${IMPORT_SEED_ON_START}" = "true" ] && [ -f "${SEED_CSV}" ] && [ ! -f /data/imports/.seed_done ]; then
echo "Seeding database from ${SEED_CSV}..." echo "Seeding from ${SEED_CSV}..."
python manage.py import_seed "${SEED_CSV}" python manage.py import_seed "${SEED_CSV}"
touch /data/imports/.seed_done touch /data/imports/.seed_done
echo "Seed import complete."
fi fi
python manage.py runserver 0.0.0.0:8000 python manage.py runserver 0.0.0.0:8000
+45 -62
View File
@@ -1,79 +1,62 @@
import os import os
from pathlib import Path from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure") SECRET_KEY = os.getenv("DJANGO_SECRET_KEY","dev-insecure")
DEBUG = os.getenv("DJANGO_DEBUG","False") == "True" DEBUG = os.getenv("DJANGO_DEBUG","False") == "True"
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS","").split(",") if os.getenv("DJANGO_ALLOWED_HOSTS") else ["*"] ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS","*").split(",")
CSRF_TRUSTED_ORIGINS = [x.strip() for x in os.getenv("CSRF_TRUSTED_ORIGINS","").split(",") if x.strip()]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin","django.contrib.auth","django.contrib.contenttypes",
"django.contrib.auth", "django.contrib.sessions","django.contrib.messages","django.contrib.staticfiles",
"django.contrib.contenttypes", "core",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"core",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = "illustrations.urls" ROOT_URLCONF = "illustrations.urls"
TEMPLATES = [{
TEMPLATES = [ "BACKEND":"django.template.backends.django.DjangoTemplates",
{ "DIRS":[BASE_DIR/"templates"],
"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS":True,
"DIRS": [BASE_DIR / "templates"], "OPTIONS":{"context_processors":[
"APP_DIRS": True, "django.template.context_processors.debug",
"OPTIONS": { "django.template.context_processors.request",
"context_processors": [ "django.contrib.auth.context_processors.auth",
"django.template.context_processors.debug", "django.contrib.messages.context_processors.messages",
"django.template.context_processors.request", ]},
"django.contrib.auth.context_processors.auth", }]
"django.contrib.messages.context_processors.messages", WSGI_APPLICATION="illustrations.wsgi.application"
],
},
},
]
WSGI_APPLICATION = "illustrations.wsgi.application"
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE":"django.db.backends.postgresql",
"NAME": "illustrations", "NAME": os.getenv("POSTGRES_DB","illustrations"),
"USER": "illustrations", "USER": os.getenv("POSTGRES_USER","illustrations"),
"PASSWORD": "illustrations", "PASSWORD": os.getenv("POSTGRES_PASSWORD","illustrations"),
"HOST": "db", "HOST": os.getenv("POSTGRES_HOST","db"),
"PORT": 5432, "PORT": int(os.getenv("POSTGRES_PORT","5432")),
} }
} }
AUTH_PASSWORD_VALIDATORS = [ LANGUAGE_CODE="en-us"
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, TIME_ZONE="America/Chicago"
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, USE_I18N=True
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, USE_TZ=True
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us" STATIC_URL="static/"
TIME_ZONE = "America/Chicago" STATIC_ROOT=BASE_DIR/"staticfiles"
USE_I18N = True STATICFILES_DIRS=[BASE_DIR/"static"]
USE_TZ = True
STATIC_URL = "static/" LOGIN_URL="/login/"
STATIC_ROOT = BASE_DIR / "staticfiles" LOGIN_REDIRECT_URL="/search/"
STATICFILES_DIRS = [BASE_DIR / "static"] LOGOUT_REDIRECT_URL="/login/"
LOGIN_URL = "/login/"
LOGIN_REDIRECT_URL = "/search/"
LOGOUT_REDIRECT_URL = "/login/"
+13 -13
View File
@@ -4,17 +4,17 @@ from django.contrib.auth import views as auth_views
from core import views as core_views from core import views as core_views
urlpatterns = [ 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.search_page, name="home"), path("", core_views.search_page, name="home"),
path("search/", core_views.search_page, name="search"), path("search/", core_views.search_page, name="search"),
path("entry/<int:entry_id>/", core_views.entry_view, name="entry_view"), path("entry/<int:entry_id>/", core_views.entry_view, name="entry_view"),
path("entry/<int:entry_id>/edit/", core_views.entry_edit, name="entry_edit"), path("entry/<int:entry_id>/edit/", core_views.entry_edit, name="entry_edit"),
path("entry/<int:entry_id>/delete/", core_views.entry_delete, name="entry_delete"), path("entry/<int:entry_id>/delete/", core_views.entry_delete, name="entry_delete"),
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("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"), path("stats/", core_views.stats_page, name="stats"),
] ]
+2 -6
View File
@@ -5,7 +5,6 @@
<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; --light:#f6f8fb; --panel:#ffffff; --line:#e5e9f2; --text:#1a1a1a; } :root { --blue:#1f6cd8; --light:#f6f8fb; --panel:#ffffff; --line:#e5e9f2; --text:#1a1a1a; }
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); } 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); }
.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; } .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; }
@@ -37,8 +36,7 @@ label { font-size:14px; color:#333; display:block; margin-bottom:6px; }
.small { font-size:12px; color:#444; } .small { font-size:12px; color:#444; }
h1 { margin:0 0 12px 0; font-size:24px; } h1 { margin:0 0 12px 0; font-size:24px; }
h2 { margin:0 0 12px 0; font-size:18px; } h2 { margin:0 0 12px 0; font-size:18px; }
</style>
</style>
</head> </head>
<body> <body>
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
@@ -54,9 +52,7 @@ h2 { margin:0 0 12px 0; font-size:18px; }
</div> </div>
{% endif %} {% endif %}
<div class="container"> <div class="container">
{% for message in messages %} {% for message in messages %}<div class="flash">{{ message }}</div>{% endfor %}
<div class="flash">{{ message }}</div>
{% endfor %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</body> </body>
+11 -33
View File
@@ -3,49 +3,27 @@
{% block content %} {% block content %}
<div class="toolbar"> <div class="toolbar">
<div class="small">Editing: Record {{ position }} of {{ count }}</div> <div class="small">Editing: Record {{ position }} of {{ count }}</div>
<div> <div><a class="btn" href="/entry/{{ entry.id }}/">Cancel</a></div>
<a class="btn" href="/entry/{{ entry.id }}/">Cancel</a>
</div>
</div> </div>
<form method="post">{% csrf_token %} <form method="post">{% csrf_token %}
<div class="panel"> <div class="panel">
<h2 style="margin-top:0;">Edit Entry</h2> <h2 style="margin-top:0;">Edit Entry</h2>
<div class="row"> <div class="row">
<div> <div><label>Talk Title</label>{{ form.talk_title }}</div>
<label>Talk Title</label> <div><label>Talk Number</label>{{ form.talk_number }}</div>
{{ form.talk_title }}
</div>
<div>
<label>Talk Number</label>
{{ form.talk_number }}
</div>
</div> </div>
<div class="row"> <div class="row">
<div> <div><label>Source</label>{{ form.source }}</div>
<label>Source</label> <div><label>Code</label>{{ form.entry_code }}</div>
{{ form.source }}
</div>
<div>
<label>Code</label>
{{ form.entry_code }}
</div>
</div> </div>
<label>Subject</label> <label>Subject</label>{{ form.subject }}
{{ form.subject }} <label>Illustration</label>{{ form.illustration }}
<label>Illustration</label> <label>Application</label>{{ form.application }}
{{ form.illustration }}
<label>Application</label>
{{ form.application }}
<div class="row"> <div class="row">
<div><label>Scripture</label>{{ form.scripture_raw }}</div>
<div> <div>
<label>Scripture</label> <label>Date Added</label>{{ form.date_added }}
{{ form.scripture_raw }} <label>Date Edited</label>{{ form.date_edited }}
</div>
<div>
<label>Date Added</label>
{{ form.date_added }}
<label>Date Edited</label>
{{ form.date_edited }}
</div> </div>
</div> </div>
<div style="margin-top:16px; display:flex; gap:10px; justify-content:flex-end;"> <div style="margin-top:16px; display:flex; gap:10px; justify-content:flex-end;">
+2 -7
View File
@@ -11,7 +11,6 @@
<a class="btn danger" href="/entry/{{ entry.id }}/delete/">Delete</a> <a class="btn danger" href="/entry/{{ entry.id }}/delete/">Delete</a>
</div> </div>
</div> </div>
<div class="panel"> <div class="panel">
<h2 style="margin-top:0;">{{ entry.talk_title|default:"(untitled)" }}</h2> <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="small">Code: {{ entry.entry_code }} • Source: {{ entry.source }} • Talk # {{ entry.talk_number }}</div>
@@ -19,15 +18,11 @@
<div class="row"> <div class="row">
<div> <div>
<label>Subject</label> <label>Subject</label>
<div class="chips"> <div class="chips">{% for t in entry.subject.split(',') %}{% if t.strip %}<span class="chip">{{ t.strip }}</span>{% endif %}{% endfor %}</div>
{% for t in entry.subject.split(',') %}{% if t.strip %}<span class="chip">{{ t.strip }}</span>{% endif %}{% endfor %}
</div>
</div> </div>
<div> <div>
<label>Scripture</label> <label>Scripture</label>
<div class="chips"> <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>
{% 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> </div>
<div class="spacer"></div> <div class="spacer"></div>
+2 -8
View File
@@ -6,14 +6,8 @@
<p>Expected headers (any order, case-insensitive): <code>Subject, Illustration, Application, Scripture, Source, Talk Title, Talk Number, Code, Date, Date Edited</code></p> <p>Expected headers (any order, case-insensitive): <code>Subject, Illustration, Application, Scripture, Source, Talk Title, Talk Number, Code, Date, Date Edited</code></p>
<form method="post" enctype="multipart/form-data">{% csrf_token %} <form method="post" enctype="multipart/form-data">{% csrf_token %}
<div class="row"> <div class="row">
<div> <div><label>CSV file</label>{{ form.file }}</div>
<label>CSV file</label> <div><label>{{ form.dry_run.label }}</label>{{ form.dry_run }} <small>{{ form.dry_run.help_text }}</small></div>
{{ form.file }}
</div>
<div>
<label>{{ form.dry_run.label }}</label>
{{ form.dry_run }} <small>{{ form.dry_run.help_text }}</small>
</div>
</div> </div>
<div style="margin-top:16px; 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/">Cancel</a> <a class="btn" href="/search/">Cancel</a>
+2 -11
View File
@@ -14,18 +14,9 @@
<div> <div>
<label>Fields to search</label> <label>Fields to search</label>
<div class="chips"> <div class="chips">
{% for field,label,checked in [ {% for opt in field_options %}
('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"> <label class="badge">
<input type="checkbox" name="{{ field }}" {% if checked %}checked{% endif %}/> {{ label }} <input type="checkbox" name="{{ opt.name }}" {% if opt.checked %}checked{% endif %}/> {{ opt.label }}
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
+2 -8
View File
@@ -13,12 +13,9 @@
<div class="card"> <div class="card">
<div class="small">Entries per month (by Date Added)</div> <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;"> <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 %} {% with peak=series|map:'1' %}{% endwith %}
{% for label, value in series %} {% 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|add:5 }}px;"></div>
<div title="{{ label }}: {{ value }}" style="width:24px; background:#dbe7ff; border:1px solid #c8d6ff; height: {{ (value|floatformat:0) }}px;"></div>
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
<div class="small" style="display:flex; gap:8px; flex-wrap:wrap; margin-top:6px;"> <div class="small" style="display:flex; gap:8px; flex-wrap:wrap; margin-top:6px;">
@@ -29,10 +26,7 @@
<h2>Top Subjects</h2> <h2>Top Subjects</h2>
<div class="cards"> <div class="cards">
{% for item in top_subjects %} {% for item in top_subjects %}
<div class="card"> <div class="card"><div style="font-weight:600;">{{ item.name }}</div><div class="small">{{ item.count }} entries</div></div>
<div style="font-weight:600;">{{ item.name }}</div>
<div class="small">{{ item.count }} entries</div>
</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>