diff --git a/library/README.md b/library/README.md index 09eb6a9..d99d497 100644 --- a/library/README.md +++ b/library/README.md @@ -207,3 +207,20 @@ quickadd/ the scan-to-add service (Python standard library + yaz) ## Signing in once Staff who are signed in to the library site (and allowed to edit the catalogue) open **Add books** without a second login. Koha itself confirms the sign-in; the shared secret is `SSO_SECRET` in `.env`. The read-only `guest` login still uses the Add books prompt, and the direct Add books port never accepts a Koha sign-in. If `SSO_SECRET` is empty, Add books just asks for its own login as before. + +## Everyday use (the Library site at `/add/`) + +One page for the day-to-day, so you rarely need Koha's own screens: + +- **Library**: a cover grid of everything you own. Search by title, author or ISBN; filter by shelf, format + (book/DVD/CD) and status (on the shelf / checked out); sort by title, author or newest. Click a book for its + page: copies with barcode, status, who has it and when it is due; change a copy's shelf or call number, + add another copy, remove a copy, or delete the book. (Title/author edits are one click away in Koha.) +- **Lend / return**: scan or type a library card number (or search by name), then scan books to check them out; + "Return a book" takes a scan with no person needed; **Renew** extends a loan; the list below shows everything + that is out, overdue ones flagged. Check-in goes through Koha's own check-in code, so holds and rules still apply. +- **People**: add someone with just a name (the card number is assigned automatically), search, and + **Print card** for a library card with a scannable Code 39 barcode. +- **Add books**: scan an ISBN (or use the phone camera), as before. + +The read-only `guest` login only sees the Library and book pages (no copy status names, no editing, no lending). diff --git a/library/koha/Dockerfile b/library/koha/Dockerfile index 03d9005..df6c195 100644 --- a/library/koha/Dockerfile +++ b/library/koha/Dockerfile @@ -41,7 +41,9 @@ RUN for c in koha-create koha-plack koha-zebra koha-rebuild-zebra koha-upgrade-s # The endpoint Quick Add uses to recognise a signed-in staff user (see the file header). COPY quickadd-session.pl /usr/share/koha/intranet/cgi-bin/svc/quickadd-session -RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session +# (the return script lives beside the staff .pl scripts, not under svc/: Koha's svc Plack app 500s on it) +COPY quickadd-return.pl /usr/share/koha/intranet/cgi-bin/quickadd-return.pl +RUN chmod 0755 /usr/share/koha/intranet/cgi-bin/svc/quickadd-session /usr/share/koha/intranet/cgi-bin/quickadd-return.pl COPY bootstrap.pl ensure.pl entrypoint.sh /opt/koha-init/ COPY seed/ /opt/koha-init/seed/ diff --git a/library/koha/quickadd-return.pl b/library/koha/quickadd-return.pl new file mode 100644 index 0000000..5ad26ac --- /dev/null +++ b/library/koha/quickadd-return.pl @@ -0,0 +1,53 @@ +#!/usr/bin/perl +# Installed as .../intranet/cgi-bin/quickadd-return.pl (URL /cgi-bin/koha/quickadd-return.pl). +# +# Koha's REST API has no "check in" call, so the Add books / Lend page asks this small script to do it +# with Koha's own C4::Circulation::AddReturn (the same code the staff Check in screen runs: it clears +# the loan, handles holds and fines rules). Same shared-secret guard as quickadd-session: only callers +# that know the secret get an answer. +use Modern::Perl; +use CGI qw( -utf8 ); +use JSON qw( encode_json ); +use Digest::SHA qw( sha256_hex ); +use C4::Context; +use C4::Circulation qw( AddReturn ); +use Koha::Items; + +my $q = CGI->new; + +sub reply { + my ( $q, $status, $data ) = @_; + print $q->header( -type => 'application/json', -status => $status, -charset => 'utf-8', -cache_control => 'no-store' ), + encode_json($data); + exit; +} + +my $secret = ''; +if ( open my $fh, '<', '/etc/koha/quickadd-sso.secret' ) { $secret = <$fh> // ''; $secret =~ s/\s+$//; close $fh } +my $given = $ENV{HTTP_X_QUICKADD_SECRET} // ''; +reply( $q, '403 Forbidden', { status => 'forbidden' } ) + unless length($secret) >= 16 && sha256_hex($given) eq sha256_hex($secret); + +my $barcode = $q->param('barcode') // ''; +reply( $q, '200 OK', { status => 'invalid' } ) unless $barcode =~ /\A[\x21-\x7e]{1,40}\z/; + +my $item = Koha::Items->find( { barcode => $barcode } ); +reply( $q, '200 OK', { status => 'unknown_barcode' } ) unless $item; + +# AddReturn needs to know which branch it is happening at. +my $branch = $item->homebranch; +my ( $doreturn, $messages, $borrower, $was_out ); +eval { + C4::Context->set_userenv( 0, 0, 'quickadd', 'Quick', 'Add', $branch, $branch, 0, '', '' ); + $was_out = $item->checkout ? 1 : 0; + ( $doreturn, $messages, undef, $borrower ) = AddReturn( $barcode, $branch ); + 1; +} or reply( $q, '200 OK', { status => 'failed', error => "$@" } ); +reply( + $q, '200 OK', + { + status => $doreturn && $was_out ? 'returned' : ( $doreturn || exists $messages->{NotIssued} ) ? 'not_out' : 'failed', + patron => $borrower ? ( join ' ', grep { length } $borrower->{firstname}, $borrower->{surname} ) : '', + messages => [ sort keys %{ $messages || {} } ], + } +); diff --git a/library/quickadd/__pycache__/app.cpython-313.pyc b/library/quickadd/__pycache__/app.cpython-313.pyc index 2b023ab..993e3c6 100644 Binary files a/library/quickadd/__pycache__/app.cpython-313.pyc and b/library/quickadd/__pycache__/app.cpython-313.pyc differ diff --git a/library/quickadd/app.py b/library/quickadd/app.py index eb74e25..3aef505 100644 --- a/library/quickadd/app.py +++ b/library/quickadd/app.py @@ -32,6 +32,7 @@ PASSWORD = os.environ.get("ADMIN_PASSWORD", "") # X-Quickadd-Proxy; we then ask Koha whether the browser's login cookie is a staff user who may edit # the catalogue. Unset = feature off (only the Basic logins below work). SSO_SECRET = os.environ.get("SSO_SECRET", "") +KOHA_RETURN_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/quickadd-return.pl" KOHA_SESSION_URL = KOHA_API.rsplit("/api/v1", 1)[0] + "/cgi-bin/koha/svc/quickadd-session" VIEWER_USER = os.environ.get("VIEWER_USER", "") VIEWER_PASSWORD = os.environ.get("VIEWER_PASSWORD", "") @@ -892,6 +893,174 @@ def op_item_copy(payload): return 200, {"status": "added", "item_id": item["item_id"], "barcode": item.get("external_id") or ""} +# ------------------------------------------------------------ lending / people +import datetime as _dt + +PATRON_CATEGORY = os.environ.get("PATRON_CATEGORY", "FRIEND") + + +def _patron_name(p): + return ("%s %s" % (p.get("firstname") or "", p.get("surname") or "")).strip() or p.get("cardnumber") or "?" + + +def _shape_patron(p): + return {"id": p["patron_id"], "name": _patron_name(p), "card": p.get("cardnumber") or "", + "email": p.get("email") or "", "phone": p.get("phone") or ""} + + +def op_patron_search(term): + term = re.sub(r"[%_\\]", "", term or "").strip()[:60] + if not term: + res = koha("GET", "/patrons", query={"_per_page": "50", "_order_by": "surname"}) or [] + else: + like = {"-like": "%" + term + "%"} + conds = [{"surname": like}, {"firstname": like}, {"cardnumber": {"-like": term + "%"}}] + parts = term.split() + if len(parts) == 2: # "Jane Smith" + conds.append({"-and": [{"firstname": {"-like": parts[0] + "%"}}, {"surname": {"-like": parts[1] + "%"}}]}) + res = koha("GET", "/patrons", query={"q": json.dumps({"-or": conds}), "_per_page": "25", "_order_by": "surname"}) or [] + return {"patrons": [_shape_patron(p) for p in res]} + + +def _loan_rows(cos): + """Checkouts (with embedded item) -> rows with titles and an overdue flag.""" + if not cos: + return [] + bib_ids = sorted({c["item"]["biblio_id"] for c in cos if c.get("item")}) + titles = {} + if bib_ids: + for b in koha("GET", "/biblios", query={"q": json.dumps({"biblio_id": bib_ids}), "_per_page": str(len(bib_ids) + 1)}) or []: + titles[b["biblio_id"]] = ((b.get("title") or "").rstrip(" /:"), b.get("author") or "", b.get("isbn") or "") + today = _dt.date.today().isoformat() + rows = [] + for c in cos: + it = c.get("item") or {} + t = titles.get(it.get("biblio_id"), ("(unknown)", "", "")) + due = (c.get("due_date") or "")[:10] + rows.append({"checkout_id": c["checkout_id"], "patron_id": c["patron_id"], "barcode": it.get("external_id") or "", + "biblio_id": it.get("biblio_id"), "title": t[0], "author": t[1], "isbn": t[2], "due": due, + "overdue": bool(due) and due < today, "renewals": c.get("renewals_count") or 0}) + return rows + + +def op_patron(patron_id): + p = koha("GET", "/patrons/%d" % patron_id) + cos = koha("GET", "/checkouts", query={"q": json.dumps({"patron_id": patron_id}), "_per_page": "200", + "_order_by": "due_date"}, headers={"x-koha-embed": "item"}) or [] + d = _shape_patron(p) + d["loans"] = _loan_rows(cos) + d["category"] = p.get("category_id") or "" + return d + + +def op_out(): + """Everything checked out right now, soonest due first (overdue on top).""" + cos = koha("GET", "/checkouts", query={"_per_page": "500", "_order_by": "due_date"}, headers={"x-koha-embed": "item"}) or [] + rows = _loan_rows(cos) + names = {} + for pid in sorted({r["patron_id"] for r in rows}): + try: + names[pid] = _patron_name(koha("GET", "/patrons/%d" % pid)) + except KohaError: + names[pid] = "?" + for r in rows: + r["who"] = names.get(r["patron_id"], "?") + return {"loans": rows} + + +def _item_by_barcode(code): + code = (code or "").strip() + if not code or len(code) > 40: + return None + res = koha("GET", "/items", query={"q": json.dumps({"external_id": code}), "_per_page": "1"}) or [] + return res[0] if res else None + + +def _title_of(biblio_id): + try: + return (koha("GET", "/biblios/%d" % biblio_id).get("title") or "").rstrip(" /:") + except KohaError: + return "" + + +def op_checkout(payload): + pid, code = payload.get("patron_id"), payload.get("barcode") + if not isinstance(pid, int): + return 400, {"status": "invalid", "message": "Pick the person first."} + it = _item_by_barcode(code) + if not it: + return 200, {"status": "unknown", "message": "No book has the barcode %s." % str(code)[:40]} + title = _title_of(it["biblio_id"]) + if item_status(it) == "Checked out": + return 200, {"status": "already_out", "title": title, "barcode": it["external_id"], + "message": "That one is already checked out. Scan it as a return first."} + try: + co = koha("POST", "/checkouts", {"patron_id": pid, "item_id": it["item_id"]}) + except KohaError as e: + try: + msg = json.loads(e.body).get("error") or "" + except ValueError: + msg = "" + return 200, {"status": "refused", "title": title, "message": msg or "Koha would not allow that checkout."} + return 200, {"status": "checked_out", "title": title, "barcode": it["external_id"], "due": (co.get("due_date") or "")[:10]} + + +def op_return(payload): + code = str(payload.get("barcode") or "").strip() + if not SSO_SECRET: + return 200, {"status": "error", "message": "Returns need SSO_SECRET in .env (run the installer's Update)."} + req = urllib.request.Request(KOHA_RETURN_URL + "?" + urllib.parse.urlencode({"barcode": code}), + headers={"X-Quickadd-Secret": SSO_SECRET}) + try: + with urllib.request.urlopen(req, timeout=20) as r: + res = json.loads(r.read()) + except (urllib.error.URLError, OSError, ValueError): + return 502, {"status": "error", "message": "Koha did not answer the return - try again."} + it = _item_by_barcode(code) if res.get("status") in ("returned", "not_out") else None + res["title"] = _title_of(it["biblio_id"]) if it else "" + return 200, res + + +def op_renew(payload): + cid = payload.get("checkout_id") + if not isinstance(cid, int): + return 400, {"status": "invalid"} + try: + co = koha("POST", "/checkouts/%d/renewal" % cid) + except KohaError as e: + try: + msg = json.loads(e.body).get("error") or "" + except ValueError: + msg = "" + return 200, {"status": "refused", "message": msg or "That one can't be renewed (limit reached or someone is waiting)."} + return 200, {"status": "renewed", "due": (co.get("due_date") or "")[:10]} + + +_NAME_CACHE = {"at": 0.0, "value": ""} + + +def library_name(): + if time.time() - _NAME_CACHE["at"] > 300: + try: + _NAME_CACHE["value"] = koha("GET", "/libraries/%s" % urllib.parse.quote(LIBRARY)).get("name") or "" + except (KohaError, AttributeError): + pass + _NAME_CACHE["at"] = time.time() + return _NAME_CACHE["value"] + + +def op_patron_add(payload): + def clean(k, n=60): + return re.sub(r"\s+", " ", str(payload.get(k) or "")).strip()[:n] + surname, first = clean("surname"), clean("firstname") + if not surname: + return 400, {"status": "invalid", "message": "A name is needed."} + body = {"surname": surname, "firstname": first or None, "category_id": PATRON_CATEGORY, "library_id": LIBRARY, + "email": clean("email", 100) or None, "phone": clean("phone", 30) or None} + p = koha("POST", "/patrons", body) + return 200, dict(_shape_patron(p), status="added") + + # ------------------------------------------------------------ photo scanning MAX_IMAGE = 12 * 1024 * 1024 ZBAR_TIMEOUT = 20 @@ -1058,11 +1227,24 @@ class Handler(BaseHTTPRequestHandler): "settings": {}, "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": []}) if path == "/api/state": - return self._send(200, {"role": "admin", "recent": load_recent()[:100], "types": get_item_types(), + return self._send(200, {"role": "admin", "library_name": library_name(), "recent": load_recent()[:100], "types": get_item_types(), "library": LIBRARY, "locations": get_locations(), "settings": load_settings(), "staff_port": STAFF_PORT, "staff_url": STAFF_URL, "opac_url": OPAC_URL, "opac_port": OPAC_PORT, "sources": [s.split(":", 1)[0] if s.startswith("z3950") else s for s in SOURCES]}) if path == "/api/settings": return self._send(200, settings_view()) + if path in ("/api/patrons", "/api/out") or path.startswith("/api/patron/"): + try: + if path == "/api/out": + return self._send(200, op_out()) + if path == "/api/patrons": + return self._send(200, op_patron_search((urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query).get("q") or [""])[0])) + m = re.match(r"^/api/patron/(\d{1,9})$", path) + if not m: + return self._send(404, {"error": "not found"}) + return self._send(200, op_patron(int(m.group(1)))) + except KohaError as e: + return self._send(404 if e.status == 404 else 502, {"status": "error", "message": + "Koha is not answering yet - try again in a minute." if e.status == 503 else "Failed: %s" % e.body[:120]}) if path == "/api/library" or path.startswith("/api/book/"): try: if path == "/api/library": @@ -1107,7 +1289,8 @@ class Handler(BaseHTTPRequestHandler): "/api/finish": op_finish, "/api/cancel": op_cancel, "/api/settings": op_settings, "/api/barcodes/reserve": op_reserve, "/api/barcodes/assign": op_assign, "/api/item/update": op_item_update, "/api/item/delete": op_item_delete, - "/api/item/copy": op_item_copy, "/api/book/delete": op_book_delete} + "/api/item/copy": op_item_copy, "/api/book/delete": op_book_delete, + "/api/checkout": op_checkout, "/api/return": op_return, "/api/renew": op_renew, "/api/patron/add": op_patron_add} fn = ops.get(self.path) if not fn: return self._send(404, {"error": "not found"}) diff --git a/library/quickadd/index.html b/library/quickadd/index.html index 5a73fb9..6ef0527 100644 --- a/library/quickadd/index.html +++ b/library/quickadd/index.html @@ -121,6 +121,16 @@ .copybox .row { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-top: 8px; } .copybox select, .copybox input[type=text] { font: inherit; padding: 7px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); min-width: 0; } button.danger { color: var(--bad); border-color: var(--bad); } + .person { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; padding: 10px 2px; border-bottom: 1px solid var(--line); } + .person .t { flex: 1; min-width: 180px; } + .loan { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--line); } + .loan .t { flex: 1; min-width: 160px; overflow-wrap: anywhere; } + .chip.late { background: var(--bad-bg); border-color: var(--bad); color: var(--bad); } + .libcard { width: 85.6mm; height: 54mm; border: 1px solid #888; border-radius: 3mm; padding: 5mm; background: #fff; color: #000; + display: flex; flex-direction: column; justify-content: space-between; font-family: system-ui, sans-serif; } + .libcard .lib { font-size: 11pt; font-weight: 700; } .libcard .nm { font-size: 14pt; font-weight: 700; } + .libcard svg { width: 100%; height: 16mm; } .libcard .no { font-size: 9pt; text-align: center; letter-spacing: .15em; } + @media print { header, .noprint, nav { display: none !important; } body { background: #fff; } main { padding: 0; max-width: none; } .libcard { border: 1px solid #000; } } .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } @@ -130,6 +140,8 @@