Merge pull request #17 from Djeeberjr/feature/idmapping

Feature/idmapping
This commit is contained in:
Psenfft 2025-05-20 16:40:52 +02:00 committed by GitHub
commit 53584fee24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1472 additions and 384 deletions

1
Cargo.lock generated
View File

@ -1116,6 +1116,7 @@ dependencies = [
"rocket_codegen",
"rocket_http",
"serde",
"serde_json",
"state",
"tempfile",
"time",

View File

@ -13,7 +13,7 @@ gpio = "0.4.1"
regex = "1.11.1"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
rocket = "0.5.1"
rocket = { version = "0.5.1", features = ["json"] }
tokio = { version = "1.44.2", features = ["full"] }
rust-embed = "8.7.0"
log = "0.4.27"

76
src/id_mapping.rs Normal file
View File

@ -0,0 +1,76 @@
use crate::tally_id::TallyID;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, Clone)]
pub struct Name {
pub first: String,
pub last: String,
}
#[derive(Deserialize, Serialize, Clone)]
pub struct IDMapping {
id_map: HashMap<TallyID, Name>,
}
impl IDMapping {
pub fn new() -> Self {
IDMapping {
id_map: HashMap::new(),
}
}
pub fn map(&self, id: &TallyID) -> Option<&Name> {
self.id_map.get(id)
}
pub fn add_mapping(&mut self, id: TallyID, name: Name) {
self.id_map.insert(id, name);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basic() {
let mut map = IDMapping::new();
let id1 = TallyID("A2Fb44".to_owned());
let name1 = Name {
first: "Max".to_owned(),
last: "Mustermann".to_owned(),
};
map.add_mapping(id1.clone(), name1.clone());
let res = map.map(&id1);
assert_eq!(res, Some(&name1));
}
#[test]
fn multiple() {
let mut map = IDMapping::new();
let id1 = TallyID("A2Fb44".to_owned());
let name1 = Name {
first: "Max".to_owned(),
last: "Mustermann".to_owned(),
};
let id2 = TallyID("7D3DF5B5".to_owned());
let name2 = Name {
first: "First".to_owned(),
last: "Last".to_owned(),
};
map.add_mapping(id1.clone(), name1.clone());
map.add_mapping(id2.clone(), name2.clone());
let res = map.map(&id1);
assert_eq!(res, Some(&name1));
let res = map.map(&id2);
assert_eq!(res, Some(&name2));
}
}

View File

@ -5,7 +5,7 @@ use std::{
};
use tokio::fs;
use crate::tally_id::TallyID;
use crate::{id_mapping::IDMapping, tally_id::TallyID};
/// Represents a single day that IDs can attend
#[derive(Deserialize, Serialize)]
@ -18,12 +18,14 @@ pub struct AttendanceDay {
#[derive(Deserialize, Serialize)]
pub struct IDStore {
days: HashMap<String, AttendanceDay>,
pub mapping: IDMapping,
}
impl IDStore {
pub fn new() -> Self {
IDStore {
days: HashMap::new(),
mapping: IDMapping::new(),
}
}
@ -82,10 +84,18 @@ impl IDStore {
days.sort();
let header = days.join(seperator);
csv.push_str(&format!("ID{}{}\n", seperator, header));
csv.push_str(&format!(
"ID{seperator}Nachname{seperator}Vorname{seperator}{header}\n"
));
for user_id in user_ids.iter() {
csv.push_str(&user_id.0.to_string());
let id = &user_id.0.to_string();
let name = self.mapping.map(user_id);
let firstname = name.map(|e| e.first.clone()).unwrap_or("".to_owned());
let lastname = name.map(|e| e.last.clone()).unwrap_or("".to_owned());
csv.push_str(&format!("{id}{seperator}{lastname}{seperator}{firstname}"));
for day in days.iter() {
let was_there: bool = self
.days
@ -95,7 +105,7 @@ impl IDStore {
.contains(user_id);
if was_there {
csv.push_str(&format!("{}x", seperator));
csv.push_str(&format!("{seperator}x"));
} else {
csv.push_str(seperator);
}

View File

@ -10,7 +10,7 @@ use std::{env, error::Error, sync::Arc};
use tally_id::TallyID;
use tokio::{
fs, join,
sync::{Mutex, mpsc},
sync::{Mutex, broadcast, mpsc},
};
use webserver::start_webserver;
@ -20,6 +20,7 @@ use mock::{MockBuzzer, MockHotspot, MockLed};
mod buzzer;
mod color;
mod hotspot;
mod id_mapping;
mod id_store;
mod led;
mod mock;
@ -138,7 +139,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
info!("Starting application");
let (tx, mut rx) = mpsc::channel::<String>(32);
let (tx, mut rx) = broadcast::channel::<String>(32);
let sse_tx = tx.clone();
tokio::spawn(async move {
match run_pm3(tx).await {
@ -178,7 +180,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
let channel_store = store.clone();
tokio::spawn(async move {
while let Some(tally_id_string) = rx.recv().await {
while let Ok(tally_id_string) = rx.recv().await {
let tally_id = TallyID(tally_id_string);
if hotspot_ids.contains(&tally_id) {
@ -203,7 +205,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
}
});
match start_webserver(store.clone()).await {
match start_webserver(store.clone(), sse_tx).await {
Ok(()) => {}
Err(e) => {
error!("Failed to start webserver: {e}");

View File

@ -4,12 +4,12 @@ use std::error::Error;
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::sync::broadcast;
/// Runs the pm3 binary and monitors it's output
/// The pm3 binary is ether set in the env var PM3_BIN or found in the path
/// The ouput is parsed and send via the `tx` channel
pub async fn run_pm3(tx: mpsc::Sender<String>) -> Result<(), Box<dyn Error>> {
pub async fn run_pm3(tx: broadcast::Sender<String>) -> Result<(), Box<dyn Error>> {
let pm3_path = match env::var("PM3_BIN") {
Ok(path) => path,
Err(_) => {
@ -35,7 +35,7 @@ pub async fn run_pm3(tx: mpsc::Sender<String>) -> Result<(), Box<dyn Error>> {
while let Some(line) = reader.next_line().await? {
trace!("PM3: {line}");
if let Some(uid) = super::parser::parse_line(&line) {
tx.send(uid).await?;
tx.send(uid)?;
}
}

View File

@ -1,21 +1,36 @@
use log::{error, info, warn};
use rocket::http::Status;
use rocket::{Config, State};
use rocket::response::stream::{Event, EventStream};
use rocket::serde::json::Json;
use rocket::{Config, State, post};
use rocket::{get, http::ContentType, response::content::RawHtml, routes};
use rust_embed::Embed;
use serde::Deserialize;
use std::borrow::Cow;
use std::env;
use std::ffi::OsStr;
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::broadcast::Sender;
use crate::id_mapping::{IDMapping, Name};
use crate::id_store::IDStore;
use crate::tally_id::TallyID;
#[derive(Embed)]
#[folder = "web/dist"]
struct Asset;
pub async fn start_webserver(store: Arc<Mutex<IDStore>>) -> Result<(), rocket::Error> {
#[derive(Deserialize)]
struct NewMapping {
id: String,
name: Name,
}
pub async fn start_webserver(
store: Arc<Mutex<IDStore>>,
sse_broadcaster: Sender<String>,
) -> Result<(), rocket::Error> {
let port = match env::var("HTTP_PORT") {
Ok(port) => port.parse().unwrap_or_else(|_| {
warn!("Failed to parse HTTP_PORT. Using default 80");
@ -31,8 +46,19 @@ pub async fn start_webserver(store: Arc<Mutex<IDStore>>) -> Result<(), rocket::E
};
rocket::custom(config)
.mount("/", routes![static_files, index, export_csv])
.mount(
"/",
routes![
static_files,
index,
export_csv,
id_event,
get_mapping,
add_mapping
],
)
.manage(store)
.manage(sse_broadcaster)
.launch()
.await?;
Ok(())
@ -57,14 +83,50 @@ fn static_files(file: std::path::PathBuf) -> Option<(ContentType, Vec<u8>)> {
Some((content_type, asset.data.into_owned()))
}
#[get("/api/idevent")]
fn id_event(sse_broadcaster: &State<Sender<String>>) -> EventStream![] {
let mut rx = sse_broadcaster.subscribe();
EventStream! {
loop {
let msg = rx.recv().await;
if let Ok(id) = msg {
yield Event::data(id);
}
}
}
}
#[get("/api/csv")]
async fn export_csv(manager: &State<Arc<Mutex<IDStore>>>) -> Result<String, Status> {
info!("Exporting CSV");
match manager.lock().await.export_csv() {
Ok(csv) => Ok(csv),
Err(e) => {
error!("Failed to generate csv: {}", e);
error!("Failed to generate csv: {e}");
Err(Status::InternalServerError)
}
}
}
#[get("/api/mapping")]
async fn get_mapping(store: &State<Arc<Mutex<IDStore>>>) -> Json<IDMapping> {
Json(store.lock().await.mapping.clone())
}
#[post("/api/mapping", format = "json", data = "<new_mapping>")]
async fn add_mapping(store: &State<Arc<Mutex<IDStore>>>, new_mapping: Json<NewMapping>) -> Status {
if new_mapping.id.is_empty()
|| new_mapping.name.first.is_empty()
|| new_mapping.name.last.is_empty()
{
return Status::BadRequest;
}
store
.lock()
.await
.mapping
.add_mapping(TallyID(new_mapping.id.clone()), new_mapping.name.clone());
Status::Created
}

View File

@ -1,6 +1,5 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@ -11,18 +10,8 @@
<link rel="manifest" href="/site.webmanifest" />
<title>Anwesenheit</title>
</head>
<body class="bg-gradient-to-br from-blue-100 to-indigo-200 min-h-screen flex flex-col items-center justify-start py-10">
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<div class="text-center space-y-6 mb-10">
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">
Anwesenheit
</div>
<a class="px-6 py-3 text-lg font-semibold text-white bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
href="/api/csv" download="anwesenheit.csv">
Download CSV
</a>
</div>
</body>
</html>

1144
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,15 +5,20 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"typescript": "~5.7.2",
"vite": "^6.2.0"
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"svelte": "^5.28.1",
"svelte-check": "^4.1.6",
"typescript": "~5.8.3",
"vite": "^6.3.5"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.4",
"tailwindcss": "^4.1.4"
"@tailwindcss/vite": "^4.1.7",
"tailwindcss": "^4.1.7"
}
}

54
web/src/App.svelte Normal file
View File

@ -0,0 +1,54 @@
<script lang="ts">
import { onMount } from "svelte";
import IDTable from "./lib/IDTable.svelte";
import LastId from "./lib/LastID.svelte";
import AddIDModal from "./lib/AddIDModal.svelte";
let lastID: string = $state("");
let addModal: AddIDModal;
let idTable: IDTable;
onMount(() => {
let sse = new EventSource("/api/idevent");
sse.onmessage = (e) => {
lastID = e.data;
};
});
</script>
<main
class="bg-gradient-to-br from-blue-100 to-indigo-200 min-h-screen flex flex-col items-center justify-start py-10"
>
<div class="text-center space-y-6 mb-10">
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">Anwesenheit</h1>
</div>
<a
class="px-6 py-3 text-lg font-semibold text-white bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
href="/api/csv"
download="anwesenheit.csv"
>
Download CSV
</a>
<div class="pt-3 pb-2">
<LastId
id={lastID}
onAdd={(id) => {
addModal.open(id);
}}
/>
</div>
<div>
<IDTable bind:this={idTable} />
</div>
<AddIDModal
bind:this={addModal}
onSubmitted={() => {
idTable.reloadData();
}}
/>
</main>

View File

@ -0,0 +1,90 @@
<script lang="ts">
import Modal from "./Modal.svelte";
let { onSubmitted }: { onSubmitted?: () => void } = $props();
let displayID = $state("");
let firstName = $state("");
let lastName = $state("");
let modal: Modal;
export function open(id: string) {
displayID = id;
modal.open();
}
function onsubmit() {
let data = {
id: displayID,
name: {
first: firstName,
last: lastName,
},
};
fetch("/api/mapping", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
}).then(() => {
onSubmitted?.();
});
firstName = "";
lastName = "";
displayID = "";
}
</script>
<Modal bind:this={modal}>
<form method="dialog" {onsubmit} class="flex flex-col">
<label class="form-row">
<span>ID:</span>
<input type="text" class="form-input" required bind:value={displayID} />
</label>
<label class="form-row">
<span>Vorname:</span>
<input type="text" class="form-input" required bind:value={firstName} />
</label>
<label class="form-row">
<span>Nachname:</span>
<input type="text" class="form-input" required bind:value={lastName} />
</label>
<div class="flex justify-end mt-3">
<button
type="reset"
class="mr-5 px-2 py-1 bg-red-500 rounded-2xl shadow-md"
onclick={() => {
modal.close();
firstName = "";
lastName = "";
displayID = "";
}}>Abbrechen</button
>
<button
type="submit"
class="px-2 py-1 bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
>Hinzufügen</button
>
</div>
</form>
</Modal>
<style scoped>
@reference "../app.css";
.form-row {
@apply flex justify-between;
}
.form-input {
@apply ml-10 border-b-1;
}
</style>

32
web/src/lib/IDMapping.ts Normal file
View File

@ -0,0 +1,32 @@
export interface IDMapping {
id_map: IDMap
}
export interface IDMap {
[name: string]: Name
}
export interface Name {
first: string,
last: string,
}
export async function addMapping(id: string, firstName: string, lastName: string) {
let req = await fetch("/api/mapping", {
method: "POST",
headers: {
"Content-type": "application/json; charset=UTF-8"
},
body: JSON.stringify({
id,
name: {
first: firstName,
lastName: lastName,
},
})
});
if (req.status != 200) {
console.error(await req.text())
}
}

106
web/src/lib/IDTable.svelte Normal file
View File

@ -0,0 +1,106 @@
<script lang="ts">
import { onMount } from "svelte";
import type { IDMapping } from "./IDMapping";
let data: IDMapping | undefined = $state();
export async function reloadData() {
let res = await fetch("/api/mapping");
data = await res.json();
}
let rows = $derived(
data
? Object.entries(data.id_map).map(([id, value]) => ({
id,
...value,
}))
: [],
);
let sortKey: keyof (typeof rows)[0] = $state("last");
let sortDirection: "asc" | "desc" = $state("asc");
let rowsSorted = $derived(
[...rows].sort((a, b) => {
let cmp = String(a[sortKey]).localeCompare(String(b[sortKey]));
return sortDirection === "asc" ? cmp : -cmp;
}),
);
function handleSortClick(key: keyof (typeof rows)[0]) {
if (sortKey === key) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortKey = key;
sortDirection = "asc";
}
}
function indicator(key: keyof (typeof rows)[0]) {
if (sortKey !== key) return "";
return sortDirection === "asc" ? "▲" : "▼";
}
onMount(async () => {
await reloadData();
});
</script>
{#if data == null}
Loading...
{:else}
<div class="bg-indigo-500 py-2 rounded-2xl overflow-x-auto">
<table class="px-10">
<thead>
<tr>
<th
class="text-left pr-5 pl-2 cursor-pointer select-none"
onclick={() => {
handleSortClick("id");
}}
>
ID
<span class="indicator">{indicator("id")}</span>
</th>
<th
class="text-left pr-5 cursor-pointer select-none"
onclick={() => {
handleSortClick("last");
}}
>
Nachname
<span class="indicator">{indicator("last")}</span>
</th>
<th
class="text-left pr-5 cursor-pointer select-none"
onclick={() => {
handleSortClick("first");
}}
>Vorname
<span class="indicator">{indicator("first")}</span>
</th>
</tr>
</thead>
<tbody>
{#each rowsSorted as row}
<tr class="even:bg-indigo-600">
<td class="whitespace-nowrap pr-5 pl-2 py-1">{row.id}</td>
<td class="whitespace-nowrap pr-5">{row.last}</td>
<td class="whitespace-nowrap pr-5">{row.first}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<style lang="css" scoped>
@reference "../app.css";
.indicator {
@apply ml-1 w-4 inline-block;
}
</style>

57
web/src/lib/LastID.svelte Normal file
View File

@ -0,0 +1,57 @@
<script lang="ts">
let { id, onAdd }: { id: string; onAdd?: (id: string) => void } = $props();
let lastID = id;
let flashing = $state(false);
$effect(() => {
if (lastID != id) {
flashing = true;
setTimeout(() => {
flashing = false;
}, 1100);
}
lastID = id;
});
</script>
<div class=" text-xl text-center">
Letzte ID
<div class="flex justify-center">
<span
class="{flashing
? 'flash'
: ''} font-bold rounded-md px-1 font-mono min-w-36">{id}</span
>
<button
class="bg-indigo-500 rounded-2xl px-2 cursor-pointer mx-2"
onclick={() => {
if (onAdd && id != "") {
onAdd(id);
}
}}>+</button
>
</div>
</div>
<style scoped>
.flash {
animation: flash-green 1s;
}
@keyframes flash-green {
0% {
background-color: transparent;
}
40% {
background-color: oklch(59.6% 0.145 163.225);
}
60% {
background-color: oklch(59.6% 0.145 163.225);
}
100% {
background-color: transparent;
}
}
</style>

33
web/src/lib/Modal.svelte Normal file
View File

@ -0,0 +1,33 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
let dialog: HTMLDialogElement;
export function open(){
dialog.showModal()
}
export function close(){
dialog.close();
}
</script>
<dialog
bind:this={dialog}
closedby="any"
class="bg-gradient-to-br from-blue-100 to-indigo-200 p-5 center-dialog rounded-2xl backdrop:bg-black/50 backdrop:backdrop-blur-xs"
>
{@render children?.()}
</dialog>
<style scoped>
.center-dialog {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>

View File

@ -1,2 +1,9 @@
import './style.css'
import { mount } from "svelte"
import "./app.css"
import App from "./App.svelte"
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app

View File

@ -1 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />

7
web/svelte.config.js Normal file
View File

@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}

20
web/tsconfig.app.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force"
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}

View File

@ -1,24 +1,7 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
web/tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,8 +1,18 @@
import { defineConfig } from "vite"
import tailwindcss from "@tailwindcss/vite"
import { svelte } from "@sveltejs/vite-plugin-svelte"
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
tailwindcss(),
svelte()
],
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
},
},
},
})