Initial Commit
continuous-integration/drone Build is passing
Details
continuous-integration/drone Build is passing
Details
commit
f857ad9a95
|
@ -0,0 +1 @@
|
|||
target/
|
|
@ -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}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/target
|
File diff suppressed because it is too large
Load Diff
|
@ -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"] }
|
|
@ -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"]
|
|
@ -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
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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...")
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
@ -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;
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
@ -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 %}
|
|
@ -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>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -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 New Issue