Initial Commit
All checks were successful
continuous-integration/drone Build is passing

This commit is contained in:
Julius 2022-06-11 23:44:44 +02:00
commit f857ad9a95
Signed by: j00lz
GPG key ID: AF241B0AA237BBA2
22 changed files with 8589 additions and 0 deletions

1
.dockerignore Normal file
View file

@ -0,0 +1 @@
target/

26
.drone.yml Normal file
View 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
View file

@ -0,0 +1 @@
/target

1735
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

19
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

1
static/css/spectre-exp.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View 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

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

33
templates/add_user.html Normal file
View 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
View 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
View 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
View 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
View 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 %}

View 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
View 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 %}