Skip to content

Commit

Permalink
Add QR code generation and scanning to UI (#808)
Browse files Browse the repository at this point in the history
* Add clickable QRCode modal in navbar

* Add qr code library

* Add QRCodeModal component to formatted refcode

* Add QRScanner functionality using `vue-qrcode-reader`

* Add QR scanning from image

* Add alert and spinner to handle camera authorisation/availaility

* Add textual label to QRCode

* Update default pURL resolver

* Add query parameter redirect-to-ui to `/items` endpoint to allow link resolver to work more smoothly

* List all QR codes in stream

* Use auxiliary variable in store to map refcodes to IDs

* Make sure item_id is set at top level of item response when querying by refcode

* Redirect to the correct UI URL

* Update config for qr code resolver and add warnings if unconfigured
  • Loading branch information
ml-evs committed Aug 30, 2024
1 parent 5bd7e24 commit e77f136
Show file tree
Hide file tree
Showing 15 changed files with 409 additions and 22 deletions.
8 changes: 7 additions & 1 deletion pydatalab/pydatalab/routes/v0_1/items.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from typing import Dict, List, Optional, Set, Union

from bson import ObjectId
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, redirect, request
from flask_login import current_user
from pydantic import ValidationError
from pymongo.command_cursor import CommandCursor
Expand Down Expand Up @@ -644,6 +644,9 @@ def get_item_data(
call its render function).
"""
redirect_to_ui = bool(request.args.get("redirect-to-ui", default=False, type=json.loads))
if refcode and redirect_to_ui and CONFIG.APP_URL:
return redirect(f"{CONFIG.APP_URL}/items/{refcode}", code=307)

if item_id:
match = {"item_id": item_id}
Expand Down Expand Up @@ -767,6 +770,9 @@ def get_item_data(
# Must be exported to JSON first to apply the custom pydantic JSON encoders
return_dict = json.loads(doc.json(exclude_unset=True))

if item_id is None:
item_id = return_dict["item_id"]

# create the files_data dictionary keyed by file ObjectId
files_data: Dict[ObjectId, Dict] = {
f["immutable_id"]: f for f in return_dict.get("files") or []
Expand Down
6 changes: 6 additions & 0 deletions pydatalab/tests/server/test_items.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ def test_single_item_endpoints(client, inserted_default_items):
for item in inserted_default_items:
response = client.get(f"/items/{item.refcode}")
assert response.status_code == 200, response.json
assert response.json["item_id"] == item.item_id
assert response.json["item_data"]["item_id"] == item.item_id
assert response.json["status"] == "success"

test_ref = item.refcode.split(":")[1]
response = client.get(f"/items/{test_ref}")
assert response.status_code == 200, response.json
assert response.json["item_id"] == item.item_id
assert response.json["item_data"]["item_id"] == item.item_id
assert response.json["status"] == "success"

response = client.get(f"/get-item-data/{item.item_id}")
assert response.status_code == 200, response.json
assert response.json["status"] == "success"
assert response.json["item_id"] == item.item_id
assert response.json["item_data"]["item_id"] == item.item_id
6 changes: 4 additions & 2 deletions webapp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "datalab-vue",
"version": "0.2.5",
"version": "0.4.5",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
Expand Down Expand Up @@ -43,7 +43,9 @@
"vue-router": "^4.0.0-0",
"vue-select": "^4.0.0-beta.6",
"vue3-easy-data-table": "^1.5.45",
"vuex": "^4.0.0-0"
"vuex": "^4.0.0-0",
"qrcode-vue3": "^1.6.8",
"vue-qrcode-reader": "^5.5.7"
},
"devDependencies": {
"@babel/core": "^7.24.8",
Expand Down
27 changes: 24 additions & 3 deletions webapp/src/components/FormattedRefcode.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
<template>
<span v-if="enableQRCode" class="badge clickable" @click="QRCodeModalOpen = true">
<font-awesome-icon
icon="qrcode"
title="Click to show QR Code for this item"
aria-label="Click to show QR Code for this item"
/>
</span>
<span
class="badge"
:class="{ clickable: enableClick || enableModifiedClick }"
Expand All @@ -9,20 +16,29 @@
>
{{ refcode }}
</span>
<QRCodeModal v-model="QRCodeModalOpen" :refcode="refcode" />
</template>

<script>
import QRCodeModal from "@/components/QRCodeModal.vue";
export default {
components: {
QRCodeModal,
},
props: {
refcode: {
type: String,
default: null,
required: true,
},
item_id: { type: String, required: true },
enableClick: {
type: Boolean,
default: false,
},
enableQRCode: {
type: Boolean,
default: true,
},
enableModifiedClick: {
type: Boolean,
default: false,
Expand All @@ -33,6 +49,11 @@ export default {
},
},
emits: ["itemIdClicked"],
data() {
return {
QRCodeModalOpen: false,
};
},
computed: {
badgeColor() {
return "LightGrey";
Expand All @@ -41,7 +62,7 @@ export default {
methods: {
openEditPageInNewTab() {
this.$emit("itemIdClicked");
window.open(`/edit/${this.item_id}`, "_blank");
window.open(`/items/${this.refcode}`, "_blank");
},
},
};
Expand Down
78 changes: 78 additions & 0 deletions webapp/src/components/QRCode.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<template>
<div class="text-center">
<QRCodeVue3
:value="QRCodeUrl"
:width="width"
:height="width"
:qr-options="{ typeNumber: 0, mode: 'Byte', errorCorrectionLevel: 'H' }"
:image-options="{ hideBackgroundDots: false, imageSize: 0, margin: 0 }"
:dots-options="{
type: 'square',
color: 'black',
}"
:background-options="{ color: '#ffffff' }"
:corners-square-options="{ type: 'square', color: 'black' }"
:corners-dot-options="{ type: 'square', color: 'black' }"
file-ext="png"
/>
<div
id="qrcode-text-label"
:style="{ width: width }"
class="qrcode-text-label mx-auto text-center center-text"
>
{{ refcode }}
</div>
</div>
<div class="alert alert-info">
QR_CODE_RESOLVER_URL is not set for this deployment.<br />
Links embedded within QR codes generated here will only work if this <i>datalab</i> instance
remains at the same URL.<br /><br />

Visit <a :href="federationQRCodeUrl">{{ federationQRCodeUrl }}</a> to learn about persistent URL
resolution in <i>datalab</i>.
</div>
</template>

<script>
import QRCodeVue3 from "qrcode-vue3";
import { FEDERATION_QR_CODE_RESOLVER_URL, QR_CODE_RESOLVER_URL, API_URL } from "@/resources.js";
export default {
name: "QRCode",
components: {
QRCodeVue3,
},
props: {
refcode: {
type: String,
required: true,
},
width: {
type: Number,
default: 200,
},
},
data() {
return {
federationQRCodeUrl: FEDERATION_QR_CODE_RESOLVER_URL,
};
},
computed: {
QRCodeUrl() {
// If the QR_CODE_RESOLVER_URL is not set, use the API_URL
// with the redirect-to-ui option
if (QR_CODE_RESOLVER_URL == null) {
return API_URL + "/items/" + this.refcode + "?redirect-to-ui=true";
}
return QR_CODE_RESOLVER_URL + "/" + this.refcode;
},
},
};
</script>

<style scoped>
.qrcode-text-label {
font-family: "Helvetica", "Arial", "Roboto Mono";
font-size: 1.8rem;
}
</style>
54 changes: 54 additions & 0 deletions webapp/src/components/QRCodeModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<template>
<form class="modal-enclosure" data-testid="qrcode-form">
<Modal :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)">
<template #header>QR Code</template>
<template #body>
<div class="form-row">
<div ref="qrcode" class="form-group mx-auto" data-testid="qrcode">
<QRCode :refcode="refcode" />
</div>
</div>
</template>
<template #footer>
<button type="submit" class="btn btn-info" value="Print" @click="printQR">Print</button>
<button
type="button"
class="btn btn-secondary"
data-dismiss="modal"
@click="$emit('update:modelValue', false)"
>
Close
</button>
</template>
</Modal>
</form>
</template>

<script>
import Modal from "@/components/Modal.vue";
import QRCode from "@/components/QRCode.vue";
export default {
name: "QRCodeModal",
components: {
QRCode,
Modal,
},
props: {
modelValue: Boolean,
refcode: { type: String, required: true },
},
emits: ["update:modelValue"],
methods: {
printQR() {
const printContents = this.$refs.qrcode.innerHTML;
const printWindow = window.open("", "", "height=400, width=800");
printWindow.document.write(
"<html><head><title>QR Code</title></head><body>" + printContents + "</body></html>",
);
printWindow.document.close();
printWindow.print();
},
},
};
</script>
90 changes: 90 additions & 0 deletions webapp/src/components/QRScannerModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<template>
<form class="modal-enclosure" data-testid="qrcode-scanner">
<Modal :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)">
<template #header>Scan QR Code</template>
<template #body>
<div v-if="modelValue" class="form-row">
<div v-if="decodedQRs != null">
<div>
Decoded QRs:
<ul>
<li v-for="qr in decodedQRs" :key="qr">
<a :href="qr">{{ qr }}</a>
</li>
</ul>
</div>
</div>
<div v-else>
<div v-show="!cameraReady">
<font-awesome-icon
v-if="!cameraReady"
icon="spinner"
class="fa-spin mx-auto"
fixed-width
style="color: gray"
size="2x"
/>
<div class="alert alert-info text-center">
No camera available. You may need to allow this page to have camera access in your
browser.
</div>
</div>
<div
v-show="cameraReady"
ref="qrcode-scanner"
class="form-group mx-auto"
data-testid="qrcode-scanner"
>
<QrcodeStream @camera-on="cameraReady = true" @detect="onDetect" />
</div>
<div ref="qrcode-upload" data-testid="qrcode-upload">
<div>Or upload an image:</div>
<QrcodeCapture id="upload-qr" class="button button-default" @detect="onDetect" />
</div>
</div>
</div>
</template>
<template #footer>
<button
type="button"
class="btn btn-secondary"
data-dismiss="modal"
@click="$emit('update:modelValue', false)"
>
Close
</button>
</template>
</Modal>
</form>
</template>

<script>
import Modal from "@/components/Modal.vue";
import { QrcodeStream, QrcodeCapture } from "vue-qrcode-reader";
export default {
name: "QRScannerModal",
components: {
Modal,
QrcodeCapture,
QrcodeStream,
},
props: {
modelValue: Boolean,
},
emits: ["update:modelValue"],
data() {
return {
decodedQRs: null,
cameraReady: false,
};
},
methods: {
onDetect(detectedQRs) {
// get all raw values from decoded QRs
this.decodedQRs = detectedQRs.map((qr) => qr.rawValue);
// Reset camera stream div
this.cameraReady = false;
},
},
};
</script>
2 changes: 2 additions & 0 deletions webapp/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
faCode,
faEnvelope,
faCog,
faQrcode,
faUsersCog,
faChevronRight,
faArrowUp,
Expand Down Expand Up @@ -51,6 +52,7 @@ library.add(
faSave,
faPen,
faCode,
faQrcode,
faEnvelope,
faCog,
faUsersCog,
Expand Down
4 changes: 4 additions & 0 deletions webapp/src/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export const API_URL =
process.env.VUE_APP_API_URL != null ? process.env.VUE_APP_API_URL : "http://localhost:5001";
export const API_TOKEN = process.env.VUE_APP_API_TOKEN;

export const QR_CODE_RESOLVER_URL = process.env.VUE_APP_QR_CODE_RESOLVER_URL;

export const FEDERATION_QR_CODE_RESOLVER_URL = "https://purl.datalab-org.io";

export const LOGO_URL = process.env.VUE_APP_LOGO_URL;
export const HOMEPAGE_URL = process.env.VUE_APP_HOMEPAGE_URL;

Expand Down
5 changes: 5 additions & 0 deletions webapp/src/router/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ const routes = [
name: "edit",
component: EditPage,
},
{
path: "/items/:refcode",
name: "edit item",
component: EditPage,
},
{
path: "/starting-materials",
name: "starting-materials",
Expand Down
Loading

0 comments on commit e77f136

Please sign in to comment.