This commit is contained in:
commit
f857ad9a95
1
.dockerignore
Normal file
1
.dockerignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
target/
|
26
.drone.yml
Normal file
26
.drone.yml
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: kubernetes
|
||||||
|
name: Build Docker image
|
||||||
|
|
||||||
|
trigger:
|
||||||
|
branch:
|
||||||
|
- main
|
||||||
|
event:
|
||||||
|
exclude:
|
||||||
|
- pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: kaniko
|
||||||
|
image: plugins/kaniko
|
||||||
|
settings:
|
||||||
|
enable-cache: true
|
||||||
|
username:
|
||||||
|
from_secret: REGISTRY_USER
|
||||||
|
password:
|
||||||
|
from_secret: REGISTRY_PASSWORD
|
||||||
|
registry: https://registry.asraphiel.dev/
|
||||||
|
repo: registry.asraphiel.dev/library/tickets
|
||||||
|
cache-repo: registry.asraphiel.dev/library/tickets-cache
|
||||||
|
tags: ${DRONE_COMMIT_BRANCH}
|
||||||
|
|
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
1735
Cargo.lock
generated
Normal file
1735
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
[package]
|
||||||
|
name = "tickets"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
askama = "0.11.1"
|
||||||
|
axum = "0.5.7"
|
||||||
|
chrono = { version = "0.4.19", features = ["serde"] }
|
||||||
|
sqlx = { version = "0.5.13", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] }
|
||||||
|
tokio = { version = "1.19.2", features = ["full"] }
|
||||||
|
uuid = { version = "0.8", features = ["serde", "v4"] }
|
||||||
|
tower-http = { version = "0.3.4", features = ["fs", "trace"] }
|
||||||
|
serde = { version = "1.0.137", features = ["derive"] }
|
||||||
|
rand = "0.8.5"
|
||||||
|
argon2 = "0.4.0"
|
||||||
|
axum-extra = { version = "0.3.4", features = ["cookie"] }
|
25
Dockerfile
Normal file
25
Dockerfile
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
from rust:1.61.0-alpine as builder
|
||||||
|
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN cargo init
|
||||||
|
COPY Cargo.* /app/
|
||||||
|
RUN cargo fetch
|
||||||
|
|
||||||
|
RUN apk add --no-cache musl-dev
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
COPY src /app/src
|
||||||
|
COPY templates /app/templates
|
||||||
|
COPY src /app/src
|
||||||
|
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
from alpine as runtime
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/release/tickets /app/tickets
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["/app/tickets"]
|
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_PASSWORD: verysecure
|
||||||
|
POSTGRES_DB: tickets
|
||||||
|
POSTGRES_USER: tickets
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
|
||||||
|
adminer:
|
||||||
|
image: adminer
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "4567:8080"
|
||||||
|
links:
|
||||||
|
- db
|
241
src/db.rs
Normal file
241
src/db.rs
Normal file
|
@ -0,0 +1,241 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use argon2::{password_hash::SaltString, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||||
|
use axum_extra::extract::cookie::Cookie;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{query, query_as, Pool};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub async fn setup_db(pool: &Pool<sqlx::Postgres>) {
|
||||||
|
query("create table if not exists parties (id serial primary key, name text)")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
query("create table if not exists tickets (id serial primary key, party_id int references parties(id), student text, inside bool)")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
query("create table if not exists users (id serial primary key, username text, password text)")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
query("create table if not exists user_tokens (id serial primary key, user_id int references users(id), token uuid, expires_at timestamp)")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Party {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Ticket {
|
||||||
|
pub id: i32,
|
||||||
|
party_id: i32,
|
||||||
|
pub student: String,
|
||||||
|
pub inside: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct User {
|
||||||
|
id: i32,
|
||||||
|
pub username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub fn verify_password(&self, password: &str) -> bool {
|
||||||
|
let hashed_password = PasswordHash::new(&self.password).unwrap();
|
||||||
|
argon2::Argon2::default()
|
||||||
|
.verify_password(password.as_bytes(), &hashed_password)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_password(&mut self, password: &str) {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = argon2::Argon2::default();
|
||||||
|
let password = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
self.password = password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct UserToken {
|
||||||
|
id: i32,
|
||||||
|
user_id: i32,
|
||||||
|
token: Uuid,
|
||||||
|
expires_at: chrono::NaiveDateTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserToken {
|
||||||
|
pub fn new(user_id: i32, expires_at: chrono::DateTime<Utc>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: 0,
|
||||||
|
user_id,
|
||||||
|
token: Uuid::new_v4(),
|
||||||
|
expires_at: expires_at.naive_utc(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_cookie(self) -> Cookie<'static> {
|
||||||
|
let mut c = Cookie::new("token", self.token.to_string());
|
||||||
|
c.make_permanent();
|
||||||
|
c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Database = Arc<Db>;
|
||||||
|
|
||||||
|
pub struct Db(Pool<sqlx::Postgres>);
|
||||||
|
|
||||||
|
impl Db {
|
||||||
|
pub fn new(pool: Pool<sqlx::Postgres>) -> Self {
|
||||||
|
Self(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_parties(&self) -> Vec<Party> {
|
||||||
|
query_as("select id, name from parties")
|
||||||
|
.fetch_all(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_party(&self, id: i32) -> Option<Party> {
|
||||||
|
query_as("select id, name from parties where id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_party_by_name(&self, name: &str) -> Option<Party> {
|
||||||
|
query_as("select id, name from parties where name = $1")
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_party(&self, name: &str) -> Party {
|
||||||
|
query_as("insert into parties (name) values ($1) returning *")
|
||||||
|
.bind(name)
|
||||||
|
.fetch_one(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_all_tickets_for_party(&self, party_id: i32) -> Vec<Ticket> {
|
||||||
|
query_as("select * from tickets where party_id = $1")
|
||||||
|
.bind(party_id)
|
||||||
|
.fetch_all(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_student_in_party(&self, party_id: i32, student: &str) -> Option<Ticket> {
|
||||||
|
query_as("select * from tickets where party_id = $1 and student = $2")
|
||||||
|
.bind(party_id)
|
||||||
|
.bind(student)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_ticket(&self, ticket: &Ticket) {
|
||||||
|
query("update tickets set inside = $1 where id = $2")
|
||||||
|
.bind(ticket.inside)
|
||||||
|
.bind(ticket.id)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_ticket(&self, party_id: i32, student: &str) -> Ticket {
|
||||||
|
query_as("insert into tickets (party_id, student, inside) values ($1, $2, $3) returning *")
|
||||||
|
.bind(party_id)
|
||||||
|
.bind(student)
|
||||||
|
.bind(false)
|
||||||
|
.fetch_one(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_username(&self, username: &str) -> Option<User> {
|
||||||
|
query_as("select id, username, password from users where username = $1")
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
pub async fn add_new_user(&self, username: &str, password: &str) -> User {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
let argon2 = argon2::Argon2::default();
|
||||||
|
let password = argon2
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.unwrap()
|
||||||
|
.to_string();
|
||||||
|
query_as("insert into users (username, password) values ($1, $2) returning *")
|
||||||
|
.bind(username)
|
||||||
|
.bind(password)
|
||||||
|
.fetch_one(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
pub async fn add_new_user_token(&self, user: &User, expires_at: DateTime<Utc>) -> UserToken {
|
||||||
|
let user_token = UserToken::new(user.id, expires_at);
|
||||||
|
query_as(
|
||||||
|
"insert into user_tokens (user_id, token, expires_at) values ($1, $2, $3) returning *",
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.bind(&user_token.token)
|
||||||
|
.bind(expires_at)
|
||||||
|
.fetch_one(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_token_by_token(&self, token: Uuid) -> Option<UserToken> {
|
||||||
|
query_as("select * from user_tokens where token = $1")
|
||||||
|
.bind(token)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_by_token(&self, token: UserToken) -> Option<User> {
|
||||||
|
query_as("select * from users where id = $1")
|
||||||
|
.bind(token.user_id)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_user(&self, user: &User) {
|
||||||
|
query("update users set username = $1, password = $2 where id = $3")
|
||||||
|
.bind(&user.username)
|
||||||
|
.bind(&user.password)
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_ticket(&self, id: i32) {
|
||||||
|
query("delete from tickets where id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
432
src/main.rs
Normal file
432
src/main.rs
Normal file
|
@ -0,0 +1,432 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use askama::Template;
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{Form, FromRequest, Path, Query, RequestParts},
|
||||||
|
http::{header, StatusCode},
|
||||||
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
|
routing::{get, get_service, post},
|
||||||
|
Extension, Json,
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum_extra::extract::{cookie::Cookie, CookieJar};
|
||||||
|
use db::{Database, Db, Party, Ticket, User};
|
||||||
|
use rand::Rng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
|
mod db;
|
||||||
|
|
||||||
|
#[axum::async_trait]
|
||||||
|
impl<B: Send> FromRequest<B> for User {
|
||||||
|
/// If the extractor fails it'll use this "rejection" type. A rejection is
|
||||||
|
/// a kind of error that can be converted into a response.
|
||||||
|
type Rejection = Redirect;
|
||||||
|
|
||||||
|
/// Perform the extraction.
|
||||||
|
async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> {
|
||||||
|
let jar = req
|
||||||
|
.extract::<CookieJar>()
|
||||||
|
.await
|
||||||
|
.or_else(|_| Err(Redirect::to("/login")))?;
|
||||||
|
|
||||||
|
let token = jar.get("token").ok_or_else(|| Redirect::to("/login"))?;
|
||||||
|
let db = req
|
||||||
|
.extensions()
|
||||||
|
.get::<Database>()
|
||||||
|
.ok_or_else(|| Redirect::to("/login"))?
|
||||||
|
.clone();
|
||||||
|
if let Ok(uuid) = uuid::Uuid::parse_str(&token.value()) {
|
||||||
|
let user = db.get_user_token_by_token(uuid).await;
|
||||||
|
if let Some(user) = user {
|
||||||
|
if let Some(user) = db.get_user_by_token(user).await {
|
||||||
|
Ok(user)
|
||||||
|
} else {
|
||||||
|
Err(Redirect::to("/login"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(Redirect::to("/login"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Err(Redirect::to("/login"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
struct IndexPage {
|
||||||
|
user: User,
|
||||||
|
parties: Vec<Party>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index(Extension(pool): Extension<Database>, user: User) -> impl IntoResponse {
|
||||||
|
let parties = pool.get_all_parties().await;
|
||||||
|
let page = IndexPage { user, parties };
|
||||||
|
Html(page.render().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "login.html")]
|
||||||
|
struct LoginPage {
|
||||||
|
not_found: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct LoginQuery {
|
||||||
|
not_found: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login_page(Query(query): Query<LoginQuery>) -> impl IntoResponse {
|
||||||
|
Html(
|
||||||
|
LoginPage {
|
||||||
|
not_found: query.not_found.unwrap_or(false),
|
||||||
|
}
|
||||||
|
.render()
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct LoginForm {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(
|
||||||
|
Form(user): Form<LoginForm>,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
jar: CookieJar,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let u = pool.get_user_by_username(&user.username).await;
|
||||||
|
if let Some(u) = u {
|
||||||
|
if u.verify_password(&user.password) {
|
||||||
|
let token = pool
|
||||||
|
.add_new_user_token(&u, chrono::Utc::now() + chrono::Duration::weeks(5))
|
||||||
|
.await;
|
||||||
|
let cookie = token.to_cookie();
|
||||||
|
(jar.add(cookie), Redirect::to("/"))
|
||||||
|
} else {
|
||||||
|
(jar, Redirect::to("/login?not_found=true"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(jar, Redirect::to("/login?not_found=true"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct ScanRequest {
|
||||||
|
code: String,
|
||||||
|
check: bool,
|
||||||
|
party: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
enum ScanState {
|
||||||
|
NotFound,
|
||||||
|
Found,
|
||||||
|
Added,
|
||||||
|
AlreadyScanned,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct ScanResponse {
|
||||||
|
code: String,
|
||||||
|
state: ScanState,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scan_card(
|
||||||
|
_: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Json(scan): Json<ScanRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let scanned = pool.is_student_in_party(scan.party, &scan.code).await;
|
||||||
|
if scan.check {
|
||||||
|
if let Some(mut ticket) = scanned {
|
||||||
|
if ticket.inside {
|
||||||
|
Json(ScanResponse {
|
||||||
|
code: scan.code,
|
||||||
|
state: ScanState::AlreadyScanned,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
ticket.inside = true;
|
||||||
|
pool.update_ticket(&ticket).await;
|
||||||
|
Json(ScanResponse {
|
||||||
|
code: scan.code,
|
||||||
|
state: ScanState::Found,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Json(ScanResponse {
|
||||||
|
code: scan.code,
|
||||||
|
state: ScanState::NotFound,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if let Some(_) = scanned {
|
||||||
|
Json(ScanResponse {
|
||||||
|
code: scan.code,
|
||||||
|
state: ScanState::AlreadyScanned,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
pool.add_ticket(scan.party, &scan.code).await;
|
||||||
|
Json(ScanResponse {
|
||||||
|
code: scan.code,
|
||||||
|
state: ScanState::Added,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct TicketsQuery {
|
||||||
|
party: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_tickets(
|
||||||
|
_: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Query(q): Query<TicketsQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let tickets = pool.get_all_tickets_for_party(q.party).await;
|
||||||
|
Json(tickets)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_parties(_: User, Extension(pool): Extension<Database>) -> impl IntoResponse {
|
||||||
|
let parties = pool.get_all_parties().await;
|
||||||
|
Json(parties)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct CreatePartyParams {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_party(
|
||||||
|
_: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Json(party): Json<CreatePartyParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let party = pool.add_party(&party.name).await;
|
||||||
|
Json(party)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_party_by_name(
|
||||||
|
_: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Json(party): Json<CreatePartyParams>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let party = pool.get_party_by_name(&party.name).await;
|
||||||
|
Json(party)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "party.html")]
|
||||||
|
struct PartyPage {
|
||||||
|
name: String,
|
||||||
|
id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn party_page(
|
||||||
|
_: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Path(party): Path<i32>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let party = pool.get_party(party).await;
|
||||||
|
if let Some(party) = party {
|
||||||
|
let page = PartyPage {
|
||||||
|
name: party.name,
|
||||||
|
id: party.id,
|
||||||
|
};
|
||||||
|
Html(page.render().unwrap()).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn export_party(
|
||||||
|
_: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Path(party): Path<i32>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let tickets = pool.get_all_tickets_for_party(party).await;
|
||||||
|
let mut csv = String::new();
|
||||||
|
csv.push_str("leerlingnummer,binnen\n");
|
||||||
|
for ticket in tickets {
|
||||||
|
csv.push_str(&format!("{},{}\n", ticket.student, ticket.inside));
|
||||||
|
}
|
||||||
|
let mut resp = Response::new(Body::from(csv));
|
||||||
|
resp.headers_mut().insert(
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
header::HeaderValue::from_static("attachment; filename=tickets.csv"),
|
||||||
|
);
|
||||||
|
resp.headers_mut().insert(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
header::HeaderValue::from_static("text/csv"),
|
||||||
|
);
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "party_goers.html")]
|
||||||
|
struct PartyGoersPage {
|
||||||
|
id: i32,
|
||||||
|
name: String,
|
||||||
|
guests: Vec<Ticket>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn party_goers(
|
||||||
|
_: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Path(party): Path<i32>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let party = pool.get_party(party).await;
|
||||||
|
if let Some(party) = party {
|
||||||
|
let guests = pool.get_all_tickets_for_party(party.id).await;
|
||||||
|
let page = PartyGoersPage {
|
||||||
|
id: party.id,
|
||||||
|
name: party.name,
|
||||||
|
guests,
|
||||||
|
};
|
||||||
|
Html(page.render().unwrap()).into_response()
|
||||||
|
} else {
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "password.html")]
|
||||||
|
struct PasswordPage {
|
||||||
|
wrong_password: bool,
|
||||||
|
wrong_dupe_password: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct PasswordParams {
|
||||||
|
wrong: Option<bool>,
|
||||||
|
dupe: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn change_password(Query(params): Query<PasswordParams>) -> impl IntoResponse {
|
||||||
|
let page = PasswordPage {
|
||||||
|
wrong_password: params.wrong.unwrap_or(false),
|
||||||
|
wrong_dupe_password: params.dupe.unwrap_or(false),
|
||||||
|
};
|
||||||
|
Html(page.render().unwrap()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct ChangePasswordForm {
|
||||||
|
password: String,
|
||||||
|
password_new: String,
|
||||||
|
password_new_again: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn change_password_post(
|
||||||
|
mut user: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Form(form): Form<ChangePasswordForm>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !user.verify_password(&form.password) {
|
||||||
|
return Redirect::to("/password?wrong=true").into_response();
|
||||||
|
}
|
||||||
|
if form.password_new != form.password_new_again {
|
||||||
|
return Redirect::to("/password?dupe=true").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
user.update_password(&form.password_new);
|
||||||
|
pool.save_user(&user).await;
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn logout(_: User, jar: CookieJar) -> impl IntoResponse {
|
||||||
|
(jar.remove(Cookie::named("token")), Redirect::to("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "add_user.html")]
|
||||||
|
struct AddUserPage {}
|
||||||
|
|
||||||
|
async fn add_user_page(_: User) -> impl IntoResponse {
|
||||||
|
Html(AddUserPage {}.render().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_user(
|
||||||
|
_: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Form(form): Form<LoginForm>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
pool.add_new_user(&form.username, &form.password).await;
|
||||||
|
Redirect::to("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct TicketDeleteRequest {
|
||||||
|
ticket_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_ticket(
|
||||||
|
_: User,
|
||||||
|
Extension(pool): Extension<Database>,
|
||||||
|
Json(ticket): Json<TicketDeleteRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
pool.remove_ticket(ticket.ticket_id).await;
|
||||||
|
"OK"
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let url = if let Some(url) = std::env::var("DATABASE_URL").ok() {
|
||||||
|
url
|
||||||
|
} else {
|
||||||
|
"postgres://postgres:postgres@localhost:5432/postgres".to_string()
|
||||||
|
};
|
||||||
|
let pool = PgPoolOptions::new()
|
||||||
|
.connect(&url)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
db::setup_db(&pool).await;
|
||||||
|
let pool = Arc::new(Db::new(pool));
|
||||||
|
if let None = pool.get_user_by_username("admin").await {
|
||||||
|
let password = if let Some(pass) = std::env::var("ADMIN_PASSWORD").ok() {
|
||||||
|
pass
|
||||||
|
} else {
|
||||||
|
rand::thread_rng()
|
||||||
|
.sample_iter(rand::distributions::Alphanumeric)
|
||||||
|
.take(32)
|
||||||
|
.map(char::from)
|
||||||
|
.collect::<String>()
|
||||||
|
};
|
||||||
|
println!("User admin was created with password {{{password}}}! Save it somewhere!");
|
||||||
|
pool.add_new_user("admin", &password).await;
|
||||||
|
}
|
||||||
|
let router = axum::Router::new()
|
||||||
|
.route("/", get(index))
|
||||||
|
.route("/login", get(login_page).post(login))
|
||||||
|
.route("/logout", get(logout))
|
||||||
|
.route("/password", get(change_password).post(change_password_post))
|
||||||
|
.route("/add_user", get(add_user_page).post(add_user))
|
||||||
|
.route("/party/:id", get(party_page))
|
||||||
|
.route("/party/:id/export", get(export_party))
|
||||||
|
.route("/party/:id/lijst", get(party_goers))
|
||||||
|
.route("/api/ticket", get(get_tickets).post(scan_card))
|
||||||
|
.route("/api/ticket/delete", post(remove_ticket))
|
||||||
|
.route("/api/party", get(get_party_by_name).post(create_party))
|
||||||
|
.route("/api/party/list", get(get_parties))
|
||||||
|
.nest(
|
||||||
|
"/static",
|
||||||
|
get_service(ServeDir::new("static")).handle_error(handle_error),
|
||||||
|
)
|
||||||
|
.layer(Extension(pool));
|
||||||
|
|
||||||
|
axum::Server::bind(&"0.0.0.0:8080".parse().unwrap())
|
||||||
|
.serve(router.into_make_service())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_error(_err: std::io::Error) -> impl IntoResponse {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong...")
|
||||||
|
}
|
1231
static/css/spectre-exp.css
Normal file
1231
static/css/spectre-exp.css
Normal file
File diff suppressed because it is too large
Load diff
1
static/css/spectre-exp.min.css
vendored
Normal file
1
static/css/spectre-exp.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
597
static/css/spectre-icons.css
Normal file
597
static/css/spectre-icons.css
Normal file
|
@ -0,0 +1,597 @@
|
||||||
|
/*! Spectre.css Icons v0.5.9 | MIT License | github.com/picturepan2/spectre */
|
||||||
|
.icon {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: inherit;
|
||||||
|
font-style: normal;
|
||||||
|
height: 1em;
|
||||||
|
position: relative;
|
||||||
|
text-indent: -9999px;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon::before,
|
||||||
|
.icon::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
left: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon.icon-2x {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon.icon-3x {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon.icon-4x {
|
||||||
|
font-size: 3.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion .icon,
|
||||||
|
.btn .icon,
|
||||||
|
.toast .icon,
|
||||||
|
.menu .icon {
|
||||||
|
vertical-align: -10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg .icon {
|
||||||
|
vertical-align: -15%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-arrow-down::before,
|
||||||
|
.icon-arrow-left::before,
|
||||||
|
.icon-arrow-right::before,
|
||||||
|
.icon-arrow-up::before,
|
||||||
|
.icon-downward::before,
|
||||||
|
.icon-back::before,
|
||||||
|
.icon-forward::before,
|
||||||
|
.icon-upward::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-right: 0;
|
||||||
|
height: .65em;
|
||||||
|
width: .65em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-arrow-down::before {
|
||||||
|
transform: translate(-50%, -75%) rotate(225deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-arrow-left::before {
|
||||||
|
transform: translate(-25%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-arrow-right::before {
|
||||||
|
transform: translate(-75%, -50%) rotate(135deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-arrow-up::before {
|
||||||
|
transform: translate(-50%, -25%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-back::after,
|
||||||
|
.icon-forward::after {
|
||||||
|
background: currentColor;
|
||||||
|
height: .1rem;
|
||||||
|
width: .8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-downward::after,
|
||||||
|
.icon-upward::after {
|
||||||
|
background: currentColor;
|
||||||
|
height: .8em;
|
||||||
|
width: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-back::after {
|
||||||
|
left: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-back::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-downward::after {
|
||||||
|
top: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-downward::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(-135deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-forward::after {
|
||||||
|
left: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-forward::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(135deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-upward::after {
|
||||||
|
top: 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-upward::before {
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-caret::before {
|
||||||
|
border-left: .3em solid transparent;
|
||||||
|
border-right: .3em solid transparent;
|
||||||
|
border-top: .3em solid currentColor;
|
||||||
|
height: 0;
|
||||||
|
transform: translate(-50%, -25%);
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-menu::before {
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 -.35em, 0 .35em;
|
||||||
|
height: .1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-apps::before {
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: -.35em -.35em, -.35em 0, -.35em .35em, 0 -.35em, 0 .35em, .35em -.35em, .35em 0, .35em .35em;
|
||||||
|
height: 3px;
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-resize-horiz::before,
|
||||||
|
.icon-resize-horiz::after,
|
||||||
|
.icon-resize-vert::before,
|
||||||
|
.icon-resize-vert::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-right: 0;
|
||||||
|
height: .45em;
|
||||||
|
width: .45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-resize-horiz::before,
|
||||||
|
.icon-resize-vert::before {
|
||||||
|
transform: translate(-50%, -90%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-resize-horiz::after,
|
||||||
|
.icon-resize-vert::after {
|
||||||
|
transform: translate(-50%, -10%) rotate(225deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-resize-horiz::before {
|
||||||
|
transform: translate(-90%, -50%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-resize-horiz::after {
|
||||||
|
transform: translate(-10%, -50%) rotate(135deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-more-horiz::before,
|
||||||
|
.icon-more-vert::before {
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: -.4em 0, .4em 0;
|
||||||
|
height: 3px;
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-more-vert::before {
|
||||||
|
box-shadow: 0 -.4em, 0 .4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-plus::before,
|
||||||
|
.icon-minus::before,
|
||||||
|
.icon-cross::before {
|
||||||
|
background: currentColor;
|
||||||
|
height: .1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-plus::after,
|
||||||
|
.icon-cross::after {
|
||||||
|
background: currentColor;
|
||||||
|
height: 100%;
|
||||||
|
width: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-cross::before {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-cross::after {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-cross::before,
|
||||||
|
.icon-cross::after {
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-check::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-right: 0;
|
||||||
|
border-top: 0;
|
||||||
|
height: .5em;
|
||||||
|
transform: translate(-50%, -75%) rotate(-45deg);
|
||||||
|
width: .9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-stop {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-stop::before {
|
||||||
|
background: currentColor;
|
||||||
|
height: .1rem;
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-shutdown {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-shutdown::before {
|
||||||
|
background: currentColor;
|
||||||
|
content: "";
|
||||||
|
height: .5em;
|
||||||
|
top: .1em;
|
||||||
|
width: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-refresh::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-right-color: transparent;
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-refresh::after {
|
||||||
|
border: .2em solid currentColor;
|
||||||
|
border-left-color: transparent;
|
||||||
|
border-top-color: transparent;
|
||||||
|
height: 0;
|
||||||
|
left: 80%;
|
||||||
|
top: 20%;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-search::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
height: .75em;
|
||||||
|
left: 5%;
|
||||||
|
top: 5%;
|
||||||
|
transform: translate(0, 0) rotate(45deg);
|
||||||
|
width: .75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-search::after {
|
||||||
|
background: currentColor;
|
||||||
|
height: .1rem;
|
||||||
|
left: 80%;
|
||||||
|
top: 80%;
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
width: .4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-edit::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
height: .4em;
|
||||||
|
transform: translate(-40%, -60%) rotate(-45deg);
|
||||||
|
width: .85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-edit::after {
|
||||||
|
border: .15em solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-top-color: transparent;
|
||||||
|
height: 0;
|
||||||
|
left: 5%;
|
||||||
|
top: 95%;
|
||||||
|
transform: translate(0, -100%);
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-delete::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom-left-radius: .1rem;
|
||||||
|
border-bottom-right-radius: .1rem;
|
||||||
|
border-top: 0;
|
||||||
|
height: .75em;
|
||||||
|
top: 60%;
|
||||||
|
width: .75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-delete::after {
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: -.25em .2em, .25em .2em;
|
||||||
|
height: .1rem;
|
||||||
|
top: .05rem;
|
||||||
|
width: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-share {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: .1rem;
|
||||||
|
border-right: 0;
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-share::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 0;
|
||||||
|
height: .4em;
|
||||||
|
left: 100%;
|
||||||
|
top: .25em;
|
||||||
|
transform: translate(-125%, -50%) rotate(-45deg);
|
||||||
|
width: .4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-share::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 75% 0;
|
||||||
|
border-right: 0;
|
||||||
|
height: .5em;
|
||||||
|
width: .6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-flag::before {
|
||||||
|
background: currentColor;
|
||||||
|
height: 1em;
|
||||||
|
left: 15%;
|
||||||
|
width: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-flag::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom-right-radius: .1rem;
|
||||||
|
border-left: 0;
|
||||||
|
border-top-right-radius: .1rem;
|
||||||
|
height: .65em;
|
||||||
|
left: 60%;
|
||||||
|
top: 35%;
|
||||||
|
width: .8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-bookmark::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-top-left-radius: .1rem;
|
||||||
|
border-top-right-radius: .1rem;
|
||||||
|
height: .9em;
|
||||||
|
width: .8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-bookmark::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: 0;
|
||||||
|
border-radius: .1rem;
|
||||||
|
height: .5em;
|
||||||
|
transform: translate(-50%, 35%) rotate(-45deg) skew(15deg, 15deg);
|
||||||
|
width: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-download,
|
||||||
|
.icon-upload {
|
||||||
|
border-bottom: .1rem solid currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-download::before,
|
||||||
|
.icon-upload::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-right: 0;
|
||||||
|
height: .5em;
|
||||||
|
transform: translate(-50%, -60%) rotate(-135deg);
|
||||||
|
width: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-download::after,
|
||||||
|
.icon-upload::after {
|
||||||
|
background: currentColor;
|
||||||
|
height: .6em;
|
||||||
|
top: 40%;
|
||||||
|
width: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-upload::before {
|
||||||
|
transform: translate(-50%, -60%) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-upload::after {
|
||||||
|
top: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-copy::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: .1rem;
|
||||||
|
border-right: 0;
|
||||||
|
height: .8em;
|
||||||
|
left: 40%;
|
||||||
|
top: 35%;
|
||||||
|
width: .8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-copy::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: .1rem;
|
||||||
|
height: .8em;
|
||||||
|
left: 60%;
|
||||||
|
top: 60%;
|
||||||
|
width: .8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-time {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-time::before {
|
||||||
|
background: currentColor;
|
||||||
|
height: .4em;
|
||||||
|
transform: translate(-50%, -75%);
|
||||||
|
width: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-time::after {
|
||||||
|
background: currentColor;
|
||||||
|
height: .3em;
|
||||||
|
transform: translate(-50%, -75%) rotate(90deg);
|
||||||
|
transform-origin: 50% 90%;
|
||||||
|
width: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-mail::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: .1rem;
|
||||||
|
height: .8em;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-mail::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-right: 0;
|
||||||
|
border-top: 0;
|
||||||
|
height: .5em;
|
||||||
|
transform: translate(-50%, -90%) rotate(-45deg) skew(10deg, 10deg);
|
||||||
|
width: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-people::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
height: .45em;
|
||||||
|
top: 25%;
|
||||||
|
width: .45em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-people::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50% 50% 0 0;
|
||||||
|
height: .4em;
|
||||||
|
top: 75%;
|
||||||
|
width: .9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-message {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: .1rem;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-message::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom-right-radius: .1rem;
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 0;
|
||||||
|
height: .8em;
|
||||||
|
left: 65%;
|
||||||
|
top: 40%;
|
||||||
|
width: .7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-message::after {
|
||||||
|
background: currentColor;
|
||||||
|
border-radius: .1rem;
|
||||||
|
height: .3em;
|
||||||
|
left: 10%;
|
||||||
|
top: 100%;
|
||||||
|
transform: translate(0, -90%) rotate(45deg);
|
||||||
|
width: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-photo {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-photo::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
height: .25em;
|
||||||
|
left: 35%;
|
||||||
|
top: 35%;
|
||||||
|
width: .25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-photo::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-left: 0;
|
||||||
|
height: .5em;
|
||||||
|
left: 60%;
|
||||||
|
transform: translate(-50%, 25%) rotate(-45deg);
|
||||||
|
width: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-link::before,
|
||||||
|
.icon-link::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 5em 0 0 5em;
|
||||||
|
border-right: 0;
|
||||||
|
height: .5em;
|
||||||
|
width: .75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-link::before {
|
||||||
|
transform: translate(-70%, -45%) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-link::after {
|
||||||
|
transform: translate(-30%, -55%) rotate(135deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-location::before {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50% 50% 50% 0;
|
||||||
|
height: .8em;
|
||||||
|
transform: translate(-50%, -60%) rotate(-45deg);
|
||||||
|
width: .8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-location::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
height: .2em;
|
||||||
|
transform: translate(-50%, -80%);
|
||||||
|
width: .2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-emoji {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-emoji::before {
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: -.17em -.1em, .17em -.1em;
|
||||||
|
height: .15em;
|
||||||
|
width: .15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-emoji::after {
|
||||||
|
border: .1rem solid currentColor;
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-right-color: transparent;
|
||||||
|
height: .5em;
|
||||||
|
transform: translate(-50%, -40%) rotate(-135deg);
|
||||||
|
width: .5em;
|
||||||
|
}
|
1
static/css/spectre-icons.min.css
vendored
Normal file
1
static/css/spectre-icons.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3760
static/css/spectre.css
Normal file
3760
static/css/spectre.css
Normal file
File diff suppressed because it is too large
Load diff
1
static/css/spectre.min.css
vendored
Normal file
1
static/css/spectre.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
33
templates/add_user.html
Normal file
33
templates/add_user.html
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
|
||||||
|
<div class="column col-md-12 col-mx-auto col-4">
|
||||||
|
<h1>Voeg een nieuwe gebruiker toe</h1>
|
||||||
|
<form method="post" class="form-horizontal">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-3 col-sm-12">
|
||||||
|
<label for="username" class="form-label">Gebruikersnaam</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 col-sm-12">
|
||||||
|
<input type="text" class="form-input" id="username" name="username" placeholder="Gebruikersnaam" autocomplete="username">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-3 col-sm-12">
|
||||||
|
<label for="password" class="form-label">Wachtwoord</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 col-sm-12">
|
||||||
|
<input type="password" class="form-input" id="password" name="password" placeholder="Wachtwoord" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button class="btn btn-primary col-12">Voeg toe</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
36
templates/base.html
Normal file
36
templates/base.html
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset='utf-8'>
|
||||||
|
<meta http-equiv='X-UA-Compatible' content='IE=edge'>
|
||||||
|
<title>Tickets</title>
|
||||||
|
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||||
|
<link rel="stylesheet" href="/static/css/spectre.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/spectre-exp.min.css">
|
||||||
|
<link rel="stylesheet" href="/static/css/spectre-icons.min.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
transition: background 0.5s, color 0.5s;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="navbar">
|
||||||
|
<section class="navbar-section">
|
||||||
|
<a href="/" class="navbar-brand mr-2">Tickets</a>
|
||||||
|
<a href="/password" class="btn btn-link">Verander Wachtwoord</a>
|
||||||
|
<a href="/add_user" class="btn btn-link">Voeg nieuwe gebruiker toe</a>
|
||||||
|
<a href="/logout" class="btn btn-link">Logout</a>
|
||||||
|
</section>
|
||||||
|
</header>
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
52
templates/index.html
Normal file
52
templates/index.html
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column col-md-12 col-mx-auto col-4">
|
||||||
|
<h1>Welkom {{user.username}}!</h1>
|
||||||
|
<h2>Voeg een nieuw feest toe</h2>
|
||||||
|
<div>
|
||||||
|
<label for="party_name">Feest Naam:</label>
|
||||||
|
<input type="text" id="party_name" name="party_name" placeholder="Feest Naam">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary" id="add_party">Voeg toe</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Kies een al bestaand feest</h2>
|
||||||
|
|
||||||
|
{% for party in parties %}
|
||||||
|
<div class="column col-12 bg-secondary text-primary text-center"><a href="/party/{{ party.id }}"
|
||||||
|
class="text-large">{{ party.name }}</a></div>
|
||||||
|
{% if !loop.last %}
|
||||||
|
<div class="divider text-center"></div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="column col-12 bg-secondary text-primary text-center">
|
||||||
|
<p class="text-large">Nog geen feesten aangemaakt!</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById("add_party").addEventListener("click", function () {
|
||||||
|
var party_name = document.getElementById("party_name").value;
|
||||||
|
fetch("/api/party", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: party_name
|
||||||
|
})
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json();
|
||||||
|
}).then(function (data) {
|
||||||
|
window.location.href = "/party/" + data.id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
43
templates/login.html
Normal file
43
templates/login.html
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="column col-md-12 col-mx-auto col-4">
|
||||||
|
<h1>Login</h1>
|
||||||
|
{% if not_found %}
|
||||||
|
<span class="label label-warning">
|
||||||
|
Deze gebruiker bestaat niet?
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="form-horizontal">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-3 col-sm-12">
|
||||||
|
<label for="username" class="form-label">Gebruikersnaam</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 col-sm-12">
|
||||||
|
<input type="text" class="form-input" id="username" name="username" placeholder="Gebruikersnaam"
|
||||||
|
autocomplete="username">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-3 col-sm-12">
|
||||||
|
<label for="password" class="form-label">Wachtwoord</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 col-sm-12">
|
||||||
|
<input type="password" class="form-input" id="password" name="password" placeholder="Wachtwoord"
|
||||||
|
autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button class="btn btn-primary col-12">Log in</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
218
templates/party.html
Normal file
218
templates/party.html
Normal file
|
@ -0,0 +1,218 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column col-md-12 col-mx-auto col-6">
|
||||||
|
<h1>{{name}}</h1>
|
||||||
|
<h2>Modus</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-radio form-inline">
|
||||||
|
<input type="radio" name="mode" value="false" checked=""><i class="form-icon"></i> Toevoegen
|
||||||
|
</label>
|
||||||
|
<label class="form-radio form-inline">
|
||||||
|
<input type="radio" name="mode" value="true"><i class="form-icon"></i> Controleren
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<h2>Camera</h2>
|
||||||
|
<div>
|
||||||
|
<p><select id="videoSource"></select><button class="btn btn-primary" id="start_scan_btn">Start
|
||||||
|
Scannen</button> </p>
|
||||||
|
<h4><span id="last-scanned">Nog geen gescand</span></h4>
|
||||||
|
<h3><span id="scan-status">Nog geen gescand</span></h3>
|
||||||
|
<video autoplay class="column col-12"></video>
|
||||||
|
</div>
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column col-md-12 col-6">
|
||||||
|
<h2>Handmatig Toevoegen</h2>
|
||||||
|
<p><label for="student_number">Leerlingnummer:</label></p>
|
||||||
|
<input type="text" id="student_number" name="student_number" placeholder="Leerlingnummer">
|
||||||
|
<button class="btn btn-primary" id="add_student">"Scan"</button>
|
||||||
|
</div>
|
||||||
|
<div class="column col-md-12 col-6">
|
||||||
|
<h2>Extra opties</h2>
|
||||||
|
<p><a href="/party/{{id}}/lijst">Bekijk een tabel in je browser</a></p>
|
||||||
|
<p><a href="/party/{{id}}/export">Exporteer als CSV</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="party_id" value="{{id}}">
|
||||||
|
<script src="//unpkg.com/javascript-barcode-reader"></script>
|
||||||
|
<script>
|
||||||
|
function set_colour(state) {
|
||||||
|
if (state == "OK") {
|
||||||
|
document.body.classList.add("bg-success");
|
||||||
|
setTimeout(function () {
|
||||||
|
document.body.classList.remove("bg-success");
|
||||||
|
}, 1000);
|
||||||
|
} else if (state == "WARN") {
|
||||||
|
document.body.classList.add("bg-warning");
|
||||||
|
setTimeout(function () {
|
||||||
|
document.body.classList.remove("bg-warning");
|
||||||
|
}, 1000);
|
||||||
|
} else if ("ERR") {
|
||||||
|
document.body.classList.add("bg-error");
|
||||||
|
setTimeout(function () {
|
||||||
|
document.body.classList.remove("bg-error");
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_scan_status(status) {
|
||||||
|
document.getElementById("scan-status").innerHTML = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function set_state(data) {
|
||||||
|
let code = data.code;
|
||||||
|
let state = data.state;
|
||||||
|
let check = document.querySelector('input[name="mode"]:checked').value === "true";
|
||||||
|
|
||||||
|
document.getElementById("last-scanned").innerText = code;
|
||||||
|
if (state === "NotFound") {
|
||||||
|
// leerlingnummer niet gevonden op de gastenlijst
|
||||||
|
// alert("Leerlingnummer niet gevonden");
|
||||||
|
set_scan_status("Heeft (waarschijnlijk) niet betaald");
|
||||||
|
set_colour("ERR");
|
||||||
|
} else if (state === "Found") {
|
||||||
|
// leerlingnummer is gevonden en mag naar binnen
|
||||||
|
// alert("Leerlingnummer gevonden");
|
||||||
|
set_scan_status("Mag naar binnen");
|
||||||
|
set_colour("OK");
|
||||||
|
} else if (state === "Added") {
|
||||||
|
// leerlingummer is toegevoegd aan de gastenlijst
|
||||||
|
// alert("Leerlingnummer toegevoegd");
|
||||||
|
set_scan_status("Toegevoegd");
|
||||||
|
set_colour("OK");
|
||||||
|
} else if (state === "AlreadyScanned") {
|
||||||
|
// leerlingnummer is al gescand, bij check==false is er al betaald, bij check==true is de leerling al binnen
|
||||||
|
if (check) {
|
||||||
|
// alert("Leerlingnummer is al binnen");
|
||||||
|
set_scan_status("Al binnen");
|
||||||
|
set_colour("ERR");
|
||||||
|
} else {
|
||||||
|
// alert("Leerlingnummer is al betaald");
|
||||||
|
set_scan_status("Al betaald");
|
||||||
|
set_colour("WARN");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert("Error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function scan_ticket(code) {
|
||||||
|
let check = document.querySelector('input[name="mode"]:checked').value === "true";
|
||||||
|
let party_id = +document.getElementById("party_id").value;
|
||||||
|
fetch("/api/ticket", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
party: party_id,
|
||||||
|
code: code,
|
||||||
|
check: check,
|
||||||
|
})
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json();
|
||||||
|
}).then(function (data) {
|
||||||
|
console.log(data)
|
||||||
|
return data;
|
||||||
|
}).then(set_state);
|
||||||
|
}
|
||||||
|
function button_click_handler() {
|
||||||
|
let code = document.getElementById("student_number").value;
|
||||||
|
scan_ticket(code);
|
||||||
|
}
|
||||||
|
document.getElementById("add_student").addEventListener("click", button_click_handler);
|
||||||
|
document.getElementById("start_scan_btn").addEventListener('click', start_scanning);
|
||||||
|
|
||||||
|
const videoElement = document.querySelector("video");
|
||||||
|
const videoSelect = document.querySelector("select#videoSource");
|
||||||
|
|
||||||
|
function start_scanning() {
|
||||||
|
navigator.mediaDevices
|
||||||
|
.enumerateDevices()
|
||||||
|
.then(gotDevices)
|
||||||
|
.then(getStream)
|
||||||
|
.catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
videoSelect.onchange = getStream;
|
||||||
|
|
||||||
|
function gotDevices(deviceInfos) {
|
||||||
|
for (let i = 0; i !== deviceInfos.length; ++i) {
|
||||||
|
const deviceInfo = deviceInfos[i];
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = deviceInfo.deviceId;
|
||||||
|
if (deviceInfo.kind === "videoinput") {
|
||||||
|
option.text = deviceInfo.label || "camera " + (videoSelect.length + 1);
|
||||||
|
videoSelect.appendChild(option);
|
||||||
|
} else {
|
||||||
|
console.log("Found another kind of device: ", deviceInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStream() {
|
||||||
|
if (window.stream) {
|
||||||
|
window.stream.getTracks().forEach(function (track) {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
deviceId: { exact: videoSelect.value },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia(constraints)
|
||||||
|
.then(gotStream)
|
||||||
|
.catch(handleError);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotStream(stream) {
|
||||||
|
window.stream = stream; // make stream available to console
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
console.log("scanning now?")
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = videoElement.videoWidth;
|
||||||
|
canvas.height = videoElement.videoHeight;
|
||||||
|
canvas.getContext("2d").drawImage(videoElement, 0, 0);
|
||||||
|
do_scan(canvas);
|
||||||
|
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(error) {
|
||||||
|
console.error("Error: ", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_scan = "";
|
||||||
|
|
||||||
|
function do_scan(imageData) {
|
||||||
|
javascriptBarcodeReader({
|
||||||
|
image: imageData,
|
||||||
|
barcode: 'codabar',
|
||||||
|
options: {
|
||||||
|
useAdaptiveThreshold: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(code => {
|
||||||
|
if (code.length == 6) {
|
||||||
|
if (last_scan === code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
last_scan = code;
|
||||||
|
scan_ticket(code);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.log(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
62
templates/party_goers.html
Normal file
62
templates/party_goers.html
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column col-md-12 col-mx-auto col-6">
|
||||||
|
<h1>{{name}}</h1>
|
||||||
|
<p><a href="/party/{{id}}">Naar het scannen</a></p>
|
||||||
|
<h2>Feestgangers</h2>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="hide-md">Ticket Nummer</th>
|
||||||
|
<th>Leerling Nummer</th>
|
||||||
|
<th>Is binnen?</th>
|
||||||
|
<th class="hide-md""></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for guest in guests %}
|
||||||
|
<tr>
|
||||||
|
<td class=" hide-md">{{guest.id}}</td>
|
||||||
|
<td>{{guest.student}}</td>
|
||||||
|
<td>{{guest.inside}}</td>
|
||||||
|
<td class="hide-md"><button class="delete-button btn btn-warning"
|
||||||
|
data-id="{{guest.id}}">Verwijder</button></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h2>Andere Opties</h2>
|
||||||
|
<p><a href="/party/{{id}}/export">Exporteer als CSV</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function delete_entry(id) {
|
||||||
|
console.log("Deleting entry with id " + id);
|
||||||
|
fetch("/api/ticket/delete", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticket_id: +id
|
||||||
|
})
|
||||||
|
}).then(function (response) {
|
||||||
|
console.log(response.ok);
|
||||||
|
return response;
|
||||||
|
}).then(function (res) {
|
||||||
|
window.location.reload();
|
||||||
|
}).catch(function (error) {
|
||||||
|
console.log(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let btns = document.querySelectorAll(".delete-button");
|
||||||
|
btns.forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
if (confirm("weet je zeker dat je dit \"kaartje\" wilt verwijderen?")) {
|
||||||
|
delete_entry(btn.dataset.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
56
templates/password.html
Normal file
56
templates/password.html
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="columns">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="column col-md-12 col-mx-auto col-2">
|
||||||
|
{% if wrong_password %}
|
||||||
|
<span class="label label-warning">
|
||||||
|
Het originele wachtwoord is verkeerd!
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if wrong_dupe_password %}
|
||||||
|
<span class="label label-warning">
|
||||||
|
Het herhaalde wachtwoord was niet het zelfds als het nieuwe wachtwoord
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="form-horizontal">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-3 col-sm-12">
|
||||||
|
<label for="password" class="form-label">Oud Wachtwoord</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 col-sm-12">
|
||||||
|
<input type="password" class="form-input" id="password" name="password" placeholder="Wachtwoord"
|
||||||
|
autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-3 col-sm-12">
|
||||||
|
<label for="password-new" class="form-label">Nieuw Wachtwoord</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 col-sm-12">
|
||||||
|
<input type="password" class="form-input" id="password-new" name="password_new"
|
||||||
|
placeholder="Wachtwoord" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-3 col-sm-12">
|
||||||
|
<label for="password-new-again" class="form-label">Nieuw Wachtwoord (herhaling)</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-9 col-sm-12">
|
||||||
|
<input type="password" class="form-input" id="password-new-again" name="password_new_again"
|
||||||
|
placeholder="Wachtwoord" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<button class="btn btn-primary col-12">Verander Wachtwoord</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
Loading…
Reference in a new issue