From e6cb7e91aae46e718064d63d35744ada8fb6511f Mon Sep 17 00:00:00 2001 From: Joshua Laymon Date: Tue, 12 Aug 2025 20:50:46 -0500 Subject: [PATCH] Initial import --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 6 + docker-compose.yml | 24 +++ web/.DS_Store | Bin 0 -> 6148 bytes web/Dockerfile | 16 ++ web/core/__init__.py | 0 web/core/admin.py | 13 ++ web/core/apps.py | 5 + web/core/forms.py | 6 + web/core/management/__init__.py | 0 web/core/management/commands/__init__.py | 0 web/core/management/commands/init_users.py | 27 ++++ web/core/migrations/0001_initial.py | 39 +++++ web/core/migrations/__init__.py | 0 web/core/models.py | 32 ++++ web/core/utils.py | 163 +++++++++++++++++++ web/core/views.py | 176 +++++++++++++++++++++ web/illustrations/__init__.py | 0 web/illustrations/settings.py | 80 ++++++++++ web/illustrations/urls.py | 20 +++ web/illustrations/wsgi.py | 5 + web/manage.py | 9 ++ web/requirements.txt | 4 + web/templates/base.html | 56 +++++++ web/templates/import_result.html | 24 +++ web/templates/import_wizard.html | 25 +++ web/templates/login.html | 19 +++ web/templates/record.html | 101 ++++++++++++ web/templates/search.html | 36 +++++ 29 files changed, 886 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitignore create mode 100644 docker-compose.yml create mode 100644 web/.DS_Store create mode 100644 web/Dockerfile create mode 100644 web/core/__init__.py create mode 100644 web/core/admin.py create mode 100644 web/core/apps.py create mode 100644 web/core/forms.py create mode 100644 web/core/management/__init__.py create mode 100644 web/core/management/commands/__init__.py create mode 100644 web/core/management/commands/init_users.py create mode 100644 web/core/migrations/0001_initial.py create mode 100644 web/core/migrations/__init__.py create mode 100644 web/core/models.py create mode 100644 web/core/utils.py create mode 100644 web/core/views.py create mode 100644 web/illustrations/__init__.py create mode 100644 web/illustrations/settings.py create mode 100644 web/illustrations/urls.py create mode 100644 web/illustrations/wsgi.py create mode 100644 web/manage.py create mode 100644 web/requirements.txt create mode 100644 web/templates/base.html create mode 100644 web/templates/import_result.html create mode 100644 web/templates/import_wizard.html create mode 100644 web/templates/login.html create mode 100644 web/templates/record.html create mode 100644 web/templates/search.html diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9933a20b091b49d05d9c64f730e0a3f53c5998a6 GIT binary patch literal 6148 zcmeHKU279T6upx~yP*g{6pBw%d`)ZGAQt+v#`@rESM;Hkn9T-Vy4?xc#6?PAe?UwB zk3Yn}qvy^{N<;Lu4dq_Ab7t!<+F-BU_1V?#Ql+w(U@tUzR!equJW51ARpnTf&YpXDul_m4Hy zi0V{=8!xleHhQjCS#ep6{j+|><18(voz8Dnt2Z7!-Uv2=&EQM)-IUREFr5{>!Q_Nn zAC1Z4MST!|N=IeC_3WL=r-L*fO>9D%j6ga4oaTusduEm=h0TpzN6-wK{nm?%i@p6$ zx2?O~gLzwXW52Cmzd4xCo5Ay!uMUs-cf*Ap~uRhT{=+MD*%v>k=9V><00p0 zkDdMy(h=957kaE5x^zjiczOhavgBb!Bk8*J}7zI2-dShc_vx*sT~`-io*2 a))3cR0frtchls%BAYf&XLKL`D1^xnOWn=;X literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e949150490eb71b8e9deeed03d49bf7a20643269 GIT binary patch literal 6148 zcmeH~&x_MQ6vy9mqnqrq(1U^qLcnWTyIaHsFR|8xS0j2*sYx5xu<1-~QmZV5+||Fu zyMKuPizofQnb~EVs(28QG7o0H^W(h<^O;O0LqwuKA0H64iO5G{^>&c`#JHc+nzd{T z#|a+eVWCE8p;FX&KxdACBk+F{;Jxe7h!o8zYwX{9=-dCUO;6(7FI|*VyU)L*(;_dc zZuhHbZF!e2Z~NQ+uKyx_trl^WRP(Z*%${-Wu~J2PR!-6Y-U1%KFzI8gc_PvRWHO3rv_$s0kJJiXrUHL6~s#N7lbKYQjnA zCu1D@WMOY8!d^T?sNG3KMqTa*I0CB#woJ9n=l_FW-~U>+I=11U8C*@CNaqk15%+ zb!~Ed)`mzgk=WR;HmXCA+2dFj_$a=Hqz!!`7od^V+9+CJ?ngjlaG4|UPYL`0 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 %}