From b784d98c9d502c2a8aa5e99aa1570bdf8dc5104c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:05:04 +0000 Subject: [PATCH] library: Lend / return, People and printable library cards in the Library site Checkout via Koha REST, check-in through a secret-guarded script that runs Koha's AddReturn, renewals, overdue list, add-a-person with auto card number, Code 39 library card page. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_013wi3ZSot4XbUViwRFgLpRf --- library/README.md | 17 ++ library/koha/Dockerfile | 4 +- library/koha/quickadd-return.pl | 53 +++++ .../quickadd/__pycache__/app.cpython-313.pyc | Bin 75979 -> 89393 bytes library/quickadd/app.py | 187 ++++++++++++++- library/quickadd/index.html | 219 +++++++++++++++++- 6 files changed, 469 insertions(+), 11 deletions(-) create mode 100644 library/koha/quickadd-return.pl 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 2b023abef3b7cbeadbf7d438745faca060f0df80..993e3c682960b6876488e158f598df23c596459b 100644 GIT binary patch delta 15198 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 z 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 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 @@

Library