commit e6cb7e91aae46e718064d63d35744ada8fb6511f Author: Joshua Laymon Date: Tue Aug 12 20:50:46 2025 -0500 Initial import diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..9933a20 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe200ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +staticfiles/ +db_data/ +__pycache__/ +*.pyc +imports/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4a55aa0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ + +version: "3.9" +services: + web: + build: ./web + env_file: + - .env + volumes: + - ./web:/app + - ./imports:/data/imports + ports: + - "8000:8000" + depends_on: + - db + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: illustrations + POSTGRES_USER: illustrations + POSTGRES_PASSWORD: illustrations + volumes: + - db_data:/var/lib/postgresql/data +volumes: + db_data: diff --git a/web/.DS_Store b/web/.DS_Store new file mode 100644 index 0000000..e949150 Binary files /dev/null and b/web/.DS_Store differ diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..d9fb418 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,16 @@ + +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app/ + +CMD ["bash", "-lc", "python manage.py migrate && python manage.py init_users && python manage.py runserver 0.0.0.0:8000"] diff --git a/web/core/__init__.py b/web/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/core/admin.py b/web/core/admin.py new file mode 100644 index 0000000..70bc9e4 --- /dev/null +++ b/web/core/admin.py @@ -0,0 +1,13 @@ + +from django.contrib import admin +from .models import Entry, ScriptureRef + +class ScriptureInline(admin.TabularInline): + model = ScriptureRef + extra = 0 + +@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") + inlines = [ScriptureInline] diff --git a/web/core/apps.py b/web/core/apps.py new file mode 100644 index 0000000..b5662cd --- /dev/null +++ b/web/core/apps.py @@ -0,0 +1,5 @@ + +from django.apps import AppConfig +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" diff --git a/web/core/forms.py b/web/core/forms.py new file mode 100644 index 0000000..53eba61 --- /dev/null +++ b/web/core/forms.py @@ -0,0 +1,6 @@ + +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") diff --git a/web/core/management/__init__.py b/web/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/core/management/commands/__init__.py b/web/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/core/management/commands/init_users.py b/web/core/management/commands/init_users.py new file mode 100644 index 0000000..9f359f9 --- /dev/null +++ b/web/core/management/commands/init_users.py @@ -0,0 +1,27 @@ + +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") + 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.save() + self.stdout.write(self.style.SUCCESS(f"Admin ready: {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.save() + self.stdout.write(self.style.SUCCESS(f"Editor ready: {editor_user}")) diff --git a/web/core/migrations/0001_initial.py b/web/core/migrations/0001_initial.py new file mode 100644 index 0000000..6d1aa4c --- /dev/null +++ b/web/core/migrations/0001_initial.py @@ -0,0 +1,39 @@ + +from django.db import migrations, models +import django.db.models.deletion + +class Migration(migrations.Migration): + initial = True + dependencies = [] + operations = [ + migrations.CreateModel( + name='Entry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subject', models.TextField(blank=True)), + ('illustration', models.TextField(blank=True)), + ('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)), + ('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)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='ScriptureRef', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('book', models.CharField(max_length=32)), + ('chapter_from', models.IntegerField(blank=True, null=True)), + ('verse_from', models.IntegerField(blank=True, null=True)), + ('chapter_to', models.IntegerField(blank=True, null=True)), + ('verse_to', models.IntegerField(blank=True, null=True)), + ('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scripture_refs', to='core.entry')), + ], + ), + ] diff --git a/web/core/migrations/__init__.py b/web/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/core/models.py b/web/core/models.py new file mode 100644 index 0000000..eb0bdb4 --- /dev/null +++ b/web/core/models.py @@ -0,0 +1,32 @@ + +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' + 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' + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.talk_title or '(untitled)'} [{self.entry_code}]" + +class ScriptureRef(models.Model): + entry = models.ForeignKey(Entry, on_delete=models.CASCADE, related_name="scripture_refs") + book = models.CharField(max_length=32) + chapter_from = models.IntegerField(null=True, blank=True) + verse_from = models.IntegerField(null=True, blank=True) + chapter_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}" diff --git a/web/core/utils.py b/web/core/utils.py new file mode 100644 index 0000000..be557a5 --- /dev/null +++ b/web/core/utils.py @@ -0,0 +1,163 @@ + +import csv, io, re +from datetime import datetime +from dateutil import parser as dateparser +from django.db.models import Q +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", + "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", + "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", + "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", +} + +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*$ +""", re.VERBOSE) + +def normalize_book(book_raw:str) -> str: + b = re.sub(r"[\.\s]","", book_raw).lower() + 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()] + parsed = [] + for p in parts: + m = SCR_REF_RE.match(p) + if not m: + parsed.append(None); continue + book_raw, ch1, v1, ch2, v2 = m.groups() + parsed.append({ + "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 + +# 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 + +def import_csv(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] + 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 "" + + 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, + entry_code=entry_code, + date_added=parse_date(get("Date")), + date_edited=parse_date(get("Date Edited")), + ) + obj = None + if entry_code: + try: obj = Entry.objects.get(entry_code=entry_code) + except Entry.DoesNotExist: obj = None + + if not dry_run: + if obj: + for k,v in data.items(): setattr(obj,k,v) + obj.save() + obj.scripture_refs.all().delete() + report["updated"] += 1 + else: + 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 + + 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_ilike(term:str)->str: + # Convert * ? to SQL ILIKE pattern + return term.replace('%','\%').replace('_','\_').replace('*','%').replace('?','_') + +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='' + 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 diff --git a/web/core/views.py b/web/core/views.py new file mode 100644 index 0000000..7056d4b --- /dev/null +++ b/web/core/views.py @@ -0,0 +1,176 @@ + +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.contrib import messages +from django.db.models import Q +import csv +from django.utils.timezone import now +from .forms import ImportForm +from .models import Entry +from .utils import import_csv, SEARCHABLE_FIELDS, build_query + +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": + username = request.POST.get("username") + password = request.POST.get("password") + user = authenticate(request, username=username, password=password) + if user: + login(request, user) + return redirect("search") + ctx["error"] = "Invalid credentials" + return render(request, "login.html", ctx) + +@login_required +def redirect_to_search(request): + return redirect("search") + +@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 + + 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) + + 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") + +@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]) + return redirect("search") + return HttpResponseForbidden("Use POST to delete.") + +@login_required +@user_passes_test(is_admin) +def import_wizard(request): + from .forms import ImportForm + if request.method == "POST": + form = ImportForm(request.POST, request.FILES) + if form.is_valid(): + fbytes = form.cleaned_data["file"].read() + dry = form.cleaned_data["dry_run"] + try: + report = import_csv(fbytes, 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}") + else: + form = ImportForm() + return render(request, "import_wizard.html", {"form": form}) + +@login_required +@user_passes_test(is_admin) +def export_csv(request): + response = HttpResponse(content_type='text/csv') + 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"]) + 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.date_added.isoformat() if e.date_added else "", + e.date_edited.isoformat() if e.date_edited else "", + ]) + return response diff --git a/web/illustrations/__init__.py b/web/illustrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web/illustrations/settings.py b/web/illustrations/settings.py new file mode 100644 index 0000000..f4a0480 --- /dev/null +++ b/web/illustrations/settings.py @@ -0,0 +1,80 @@ + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-insecure") +DEBUG = os.getenv("DJANGO_DEBUG","False") == "True" +ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS","").split(",") if os.getenv("DJANGO_ALLOWED_HOSTS") else ["*"] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "core", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "illustrations.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "illustrations.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "illustrations", + "USER": "illustrations", + "PASSWORD": "illustrations", + "HOST": "db", + "PORT": 5432, + } +} + +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "America/Chicago" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STATICFILES_DIRS = [BASE_DIR / "static"] + +LOGIN_URL = "/login/" +LOGIN_REDIRECT_URL = "/search/" +LOGOUT_REDIRECT_URL = "/login/" diff --git a/web/illustrations/urls.py b/web/illustrations/urls.py new file mode 100644 index 0000000..58e5db5 --- /dev/null +++ b/web/illustrations/urls.py @@ -0,0 +1,20 @@ + +from django.contrib import admin +from django.urls import path +from django.contrib.auth import views as auth_views +from core import views as core_views + +urlpatterns = [ + path("admin/", admin.site.urls), + path("login/", core_views.login_view, name="login"), + path("logout/", auth_views.LogoutView.as_view(), name="logout"), + path("", core_views.redirect_to_search), + path("search/", core_views.search_view, name="search"), + path("record//", core_views.record_view, name="record_view"), + path("record//save/", core_views.record_save, name="record_save"), + path("record//delete/", core_views.record_delete, name="record_delete"), + path("nav/prev/", core_views.nav_prev, name="nav_prev"), + path("nav/next/", core_views.nav_next, name="nav_next"), + path("import/", core_views.import_wizard, name="import_wizard"), + path("export/csv/", core_views.export_csv, name="export_csv"), +] diff --git a/web/illustrations/wsgi.py b/web/illustrations/wsgi.py new file mode 100644 index 0000000..057e7b4 --- /dev/null +++ b/web/illustrations/wsgi.py @@ -0,0 +1,5 @@ + +import os +from django.core.wsgi import get_wsgi_application +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'illustrations.settings') +application = get_wsgi_application() diff --git a/web/manage.py b/web/manage.py new file mode 100644 index 0000000..92e80ef --- /dev/null +++ b/web/manage.py @@ -0,0 +1,9 @@ + +#!/usr/bin/env python +import os, sys +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'illustrations.settings') + from django.core.management import execute_from_command_line + execute_from_command_line(sys.argv) +if __name__ == '__main__': + main() diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..23c71a2 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,4 @@ + +Django==5.0.6 +psycopg2-binary==2.9.9 +python-dateutil==2.9.0.post0 diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..5858a93 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,56 @@ + + + + + + + {% block title %}Illustrations DB{% endblock %} + + + + {% if request.user.is_authenticated %} +
+
Illustrations Database
+ +
+ {% endif %} +
+ {% for message in messages %}
{{ message }}
{% endfor %} + {% block content %}{% endblock %} +
+ + diff --git a/web/templates/import_result.html b/web/templates/import_result.html new file mode 100644 index 0000000..22af6c5 --- /dev/null +++ b/web/templates/import_result.html @@ -0,0 +1,24 @@ + +{% extends "base.html" %} +{% block title %}Import Result - Illustrations DB{% endblock %} +{% block content %} +
+

Import {{ "Preview" if dry_run else "Result" }}

+
    +
  • Total rows: {{ report.rows }}
  • +
  • Inserted: {{ report.inserted }}
  • +
  • Updated: {{ report.updated }}
  • +
  • Skipped: {{ report.skipped }}
  • +
  • Scripture parsed: {{ report.scripture_parsed }}
  • +
  • Scripture failed: {{ report.scripture_failed }}
  • +
+ {% if report.errors and report.errors|length %} +

Errors

+
{{ report.errors|join("\n") }}
+ {% endif %} +
+ Run again + Done +
+
+{% endblock %} diff --git a/web/templates/import_wizard.html b/web/templates/import_wizard.html new file mode 100644 index 0000000..5db9ae2 --- /dev/null +++ b/web/templates/import_wizard.html @@ -0,0 +1,25 @@ + +{% extends "base.html" %} +{% block title %}Import Data - Illustrations DB{% endblock %} +{% block content %} +
+

Import Data (CSV)

+

Expected headers (any order, case-insensitive): Subject, Illustration, Application, Scripture, Source, Talk Title, Talk Number, Code, Date, Date Edited

+
{% csrf_token %} +
+
+ + {{ form.file }} +
+
+ + {{ form.dry_run }} {{ form.dry_run.help_text }} +
+
+
+ Cancel + +
+
+
+{% endblock %} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..cd1a198 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,19 @@ + +{% extends "base.html" %} +{% block title %}Sign in - Illustrations DB{% endblock %} +{% block content %} +
+

Sign in

+ {% if error %}
{{ error }}
{% endif %} +
{% csrf_token %} + + + + +
+ Cancel + +
+
+
+{% endblock %} diff --git a/web/templates/record.html b/web/templates/record.html new file mode 100644 index 0000000..3c47bae --- /dev/null +++ b/web/templates/record.html @@ -0,0 +1,101 @@ + +{% extends "base.html" %} +{% block title %}Record - Illustrations DB{% endblock %} +{% block content %} +
+
Total: {{ total }}
+
Results: {{ results_count }}
+
Viewing: {{ position }} of {{ results_count|default:1 }}
+
+ +
+
+ +
+ +
+ {% csrf_token %} + +
+
+
+ +
+ {% csrf_token %} +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+ Back to Search + +
+
+
+ + +{% endblock %} diff --git a/web/templates/search.html b/web/templates/search.html new file mode 100644 index 0000000..3fec5e5 --- /dev/null +++ b/web/templates/search.html @@ -0,0 +1,36 @@ + +{% extends "base.html" %} +{% block title %}Search - Illustrations DB{% endblock %} +{% block content %} +
+
+
+
+ + +
+
+ +
+ {% for f in fields %} + + {% endfor %} +
+
+
+
+ Clear + +
+
+
+
+
Total entries: {{ total }}
+ {% if results_count %}
Results: {{ results_count }}
{% endif %} +
+ {% if results_count %} +
+

Opening first result…

+
+ {% endif %} +{% endblock %}