Skip to content

Commit

Permalink
Implement badge barcode scanning (#374)
Browse files Browse the repository at this point in the history
* Install `html5-qrcode` for scanning barcodes

* Store and provide badge number for participants

- Update check-in POST request to send badge number
- Update check-in endpoint to store badge number
- Update participants endpoint to provide badge numbers

* Implement badge scanner with html5-qrcode

- Still some weird `useEffect` issues with scanner opening twice
  • Loading branch information
taesungh authored Jan 26, 2024
1 parent 06075bb commit 95eb46d
Show file tree
Hide file tree
Showing 7 changed files with 135 additions and 17 deletions.
22 changes: 10 additions & 12 deletions apps/api/src/admin/participant_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ class Participant(UserRecord):
first_name: str
last_name: str
status: Union[Status, Decision] = Status.REVIEWED
badge_number: Union[str, None] = None


PARTICIPANT_FIELDS = ["_id", "status", "role", "checkins", "badge_number"]


async def get_hackers() -> list[Participant]:
Expand All @@ -49,14 +53,8 @@ async def get_hackers() -> list[Participant]:
]
},
},
[
"_id",
"status",
"role",
"checkins",
"application_data.first_name",
"application_data.last_name",
],
PARTICIPANT_FIELDS
+ ["application_data.first_name", "application_data.last_name"],
)

return [Participant(**user, **user["application_data"]) for user in records]
Expand All @@ -67,12 +65,12 @@ async def get_non_hackers() -> list[Participant]:
records: list[dict[str, Any]] = await mongodb_handler.retrieve(
Collection.USERS,
{"role": {"$in": NON_HACKER_ROLES}},
["_id", "status", "role", "checkins", "first_name", "last_name"],
PARTICIPANT_FIELDS + ["first_name", "last_name"],
)
return [Participant(**user) for user in records]


async def check_in_participant(uid: str, associate: User) -> None:
async def check_in_participant(uid: str, badge_number: str, associate: User) -> None:
"""Check in participant at IrvineHacks"""
record: Optional[dict[str, object]] = await mongodb_handler.retrieve_one(
Collection.USERS, {"_id": uid, "role": {"$exists": True}}
Expand All @@ -91,13 +89,13 @@ async def check_in_participant(uid: str, associate: User) -> None:
{"_id": uid},
{
"$push": {"checkins": new_checkin_entry},
"$set": {"badge_number": badge_number},
},
)
if not update_status:
raise RuntimeError(f"Could not update check-in record for {uid}.")

log.info(f"Applicant {uid} checked in by {associate.uid}")

log.info(f"Applicant {uid} ({badge_number}) checked in by {associate.uid}")

async def confirm_attendance_non_hacker(uid: str, director: User) -> None:
"""Update status for Role.Attending for non-hackers."""
Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,11 +255,13 @@ async def participants() -> list[Participant]:

@router.post("/checkin/{uid}")
async def check_in_participant(
uid: str, associate: Annotated[User, Depends(require_checkin_associate)]
uid: str,
badge_number: Annotated[str, Body()],
associate: Annotated[User, Depends(require_checkin_associate)],
) -> None:
"""Check in participant at IrvineHacks."""
try:
await participant_manager.check_in_participant(uid, associate)
await participant_manager.check_in_participant(uid, badge_number, associate)
except ValueError:
raise HTTPException(status.HTTP_404_NOT_FOUND)
except RuntimeError as err:
Expand Down
1 change: 1 addition & 0 deletions apps/site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"date-fns-tz": "^2.0.0",
"dayjs": "^1.11.10",
"framer-motion": "^10.16.14",
"html5-qrcode": "^2.3.8",
"lucide-react": "^0.292.0",
"next": "13.5.6",
"next-sanity": "^5.5.4",
Expand Down
52 changes: 50 additions & 2 deletions apps/site/src/app/admin/participants/components/CheckInModal.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";

import Box from "@cloudscape-design/components/box";
import Button from "@cloudscape-design/components/button";
import Input from "@cloudscape-design/components/input";
import Modal from "@cloudscape-design/components/modal";
import SpaceBetween from "@cloudscape-design/components/space-between";
import TextContent from "@cloudscape-design/components/text-content";

import BadgeScanner from "@/lib/admin/BadgeScanner";
import { Participant } from "@/lib/admin/useParticipants";

export interface ActionModalProps {
Expand All @@ -13,7 +17,31 @@ export interface ActionModalProps {
}

function CheckInModal({ onDismiss, onConfirm, participant }: ActionModalProps) {
const [badgeNumber, setBadgeNumber] = useState(
participant?.badge_number ?? "",
);
const [showScanner, setShowScanner] = useState(true);

const onScanSuccess = useCallback((decodedText: string) => {
setBadgeNumber(decodedText);
setShowScanner(false);
}, []);

useEffect(() => {
console.log("new participant", participant);
setBadgeNumber(participant?.badge_number ?? "");
setShowScanner(participant?.badge_number === null);
}, [participant]);

const badgeScanner = useMemo(
() => <BadgeScanner onSuccess={onScanSuccess} onError={() => null} />,
[onScanSuccess],
);

if (participant === null) {
if (showScanner) {
setShowScanner(false);
}
return <Modal visible={false} />;
}

Expand All @@ -27,7 +55,15 @@ function CheckInModal({ onDismiss, onConfirm, participant }: ActionModalProps) {
<Button variant="link" onClick={onDismiss}>
Cancel
</Button>
<Button variant="primary" onClick={() => onConfirm(participant)}>
<Button
variant="primary"
onClick={() =>
onConfirm({
...participant,
badge_number: badgeNumber,
})
}
>
Check In
</Button>
</SpaceBetween>
Expand All @@ -45,7 +81,19 @@ function CheckInModal({ onDismiss, onConfirm, participant }: ActionModalProps) {
<li>Fill in badge and give to participant.</li>
</ul>
</TextContent>
{/* TODO: badge barcode input */}
{showScanner && badgeScanner}
<SpaceBetween direction="horizontal" size="xs">
<Input
onChange={({ detail }) => setBadgeNumber(detail.value)}
value={badgeNumber}
/>
<Button
iconName="video-on"
variant="icon"
onClick={() => setShowScanner(true)}
iconAlt="Scan with camera"
/>
</SpaceBetween>
</SpaceBetween>
</Modal>
);
Expand Down
56 changes: 56 additions & 0 deletions apps/site/src/lib/admin/BadgeScanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useEffect } from "react";

import {
Html5QrcodeScanner,
QrcodeErrorCallback,
QrcodeSuccessCallback,
} from "html5-qrcode";

const scannerRegionId = "badge-scanner-full-region";

interface BadgeScannerProps {
fps?: number;
qrbox?: number;
aspectRatio?: number;
disableFlip?: boolean;
verbose?: boolean;
onSuccess: QrcodeSuccessCallback;
onError: QrcodeErrorCallback;
}

function BadgeScanner(props: BadgeScannerProps) {
useEffect(() => {
const {
fps,
qrbox,
aspectRatio,
disableFlip,
verbose,
onSuccess,
onError,
} = props;

const html5QrcodeScanner = new Html5QrcodeScanner(
scannerRegionId,
{
fps: fps ?? 5,
qrbox: qrbox ?? 300,
aspectRatio: aspectRatio ?? 1,
disableFlip: disableFlip ?? true,
},
verbose ?? false,
);
html5QrcodeScanner.render(onSuccess, onError);

// Clean up when unmount
return () => {
html5QrcodeScanner.clear().catch((error) => {
console.error("Failed to clear html5QrcodeScanner. ", error);
});
};
}, [props]);

return <div id={scannerRegionId} />;
}

export default BadgeScanner;
8 changes: 7 additions & 1 deletion apps/site/src/lib/admin/useParticipants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface Participant {
role: Role;
checkins: Checkin[];
status: Status;
badge_number: string | null;
}

const fetcher = async (url: string) => {
Expand All @@ -40,7 +41,12 @@ function useParticipants() {
const checkInParticipant = async (participant: Participant) => {
console.log("Checking in", participant);
// TODO: implement mutation for showing checked in on each day
await axios.post(`/api/admin/checkin/${participant._id}`);
// Note: Will cause 422 if badge number is null, but in practice,
// this should never happen
await axios.post(
`/api/admin/checkin/${participant._id}`,
participant.badge_number,
);
mutate();
};

Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 95eb46d

Please sign in to comment.