Refactored file structure and added admin dashboard
This commit is contained in:
parent
54ffb721dd
commit
f823028d17
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
questions.db
|
questions.db
|
||||||
|
instance/
|
||||||
.env
|
.env
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
131
app.py
131
app.py
@ -1,134 +1,7 @@
|
|||||||
from flask import Flask, render_template, request, redirect
|
from app import create_app
|
||||||
from datetime import date
|
|
||||||
import sqlite3
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
|
||||||
|
|
||||||
DB = "questions.db"
|
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
app = create_app()
|
||||||
return sqlite3.connect(DB)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/")
|
|
||||||
def home():
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT *
|
|
||||||
FROM questions
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
""")
|
|
||||||
|
|
||||||
question = cur.fetchone()
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"index.html",
|
|
||||||
question=question
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/vote", methods=["POST"])
|
|
||||||
def vote():
|
|
||||||
|
|
||||||
qid = request.form["qid"]
|
|
||||||
vote = request.form["vote"]
|
|
||||||
|
|
||||||
ip = request.remote_addr
|
|
||||||
|
|
||||||
voter_hash = hashlib.sha256(
|
|
||||||
ip.encode()
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT *
|
|
||||||
FROM votes
|
|
||||||
WHERE voter_hash = ?
|
|
||||||
AND question_id = ?
|
|
||||||
""", (voter_hash, qid))
|
|
||||||
|
|
||||||
existing = cur.fetchone()
|
|
||||||
|
|
||||||
if not existing:
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO votes
|
|
||||||
(question_id, vote, voter_hash)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
""", (qid, vote, voter_hash))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return redirect("/results")
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/results")
|
|
||||||
def results():
|
|
||||||
|
|
||||||
conn = get_db()
|
|
||||||
cur = conn.cursor()
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT *
|
|
||||||
FROM questions
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
""")
|
|
||||||
|
|
||||||
question = cur.fetchone()
|
|
||||||
|
|
||||||
qid = question[0]
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM votes
|
|
||||||
WHERE question_id = ?
|
|
||||||
AND vote = 'A'
|
|
||||||
""", (qid,))
|
|
||||||
|
|
||||||
votes_a = cur.fetchone()[0]
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM votes
|
|
||||||
WHERE question_id = ?
|
|
||||||
AND vote = 'B'
|
|
||||||
""", (qid,))
|
|
||||||
|
|
||||||
votes_b = cur.fetchone()[0]
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
total = votes_a + votes_b
|
|
||||||
|
|
||||||
if total == 0:
|
|
||||||
percent_a = 0
|
|
||||||
percent_b = 0
|
|
||||||
else:
|
|
||||||
percent_a = round(votes_a / total * 100)
|
|
||||||
percent_b = round(votes_b / total * 100)
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"results.html",
|
|
||||||
question=question,
|
|
||||||
votes_a=votes_a,
|
|
||||||
votes_b=votes_b,
|
|
||||||
percent_a=percent_a,
|
|
||||||
percent_b=percent_b,
|
|
||||||
total=total
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
29
app/__init__.py
Normal file
29
app/__init__.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(test_config=None):
|
||||||
|
app = Flask(__name__, instance_relative_config=True)
|
||||||
|
|
||||||
|
app.config.from_mapping(
|
||||||
|
SECRET_KEY=os.environ.get("WYR_SECRET_KEY", "dev-only-change-me"),
|
||||||
|
DATABASE=os.path.join(app.instance_path, "questions.db"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if test_config is not None:
|
||||||
|
app.config.update(test_config)
|
||||||
|
|
||||||
|
os.makedirs(app.instance_path, exist_ok=True)
|
||||||
|
|
||||||
|
from . import database
|
||||||
|
|
||||||
|
app.teardown_appcontext(database.close_db)
|
||||||
|
|
||||||
|
from .admin import bp as admin_bp
|
||||||
|
from .routes import bp as public_bp
|
||||||
|
|
||||||
|
app.register_blueprint(public_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
|
|
||||||
|
return app
|
||||||
301
app/admin.py
Normal file
301
app/admin.py
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
from functools import wraps
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from flask import (
|
||||||
|
Blueprint,
|
||||||
|
flash,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
session,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
from .database import get_db
|
||||||
|
|
||||||
|
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapped_view(**kwargs):
|
||||||
|
if not session.get("admin"):
|
||||||
|
return redirect(url_for("admin.login"))
|
||||||
|
|
||||||
|
return view(**kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
def validate_question_form(form, question_id=None):
|
||||||
|
question = form.get("question", "").strip()
|
||||||
|
option_a = form.get("option_a", "").strip()
|
||||||
|
option_b = form.get("option_b", "").strip()
|
||||||
|
question_date = form.get("date", "").strip()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"question": question,
|
||||||
|
"option_a": option_a,
|
||||||
|
"option_b": option_b,
|
||||||
|
"date": question_date,
|
||||||
|
}
|
||||||
|
|
||||||
|
if not all(data.values()):
|
||||||
|
return data, "All fields are required."
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
params = [question_date]
|
||||||
|
duplicate_sql = "SELECT id FROM questions WHERE date = ?"
|
||||||
|
|
||||||
|
if question_id is not None:
|
||||||
|
duplicate_sql += " AND id != ?"
|
||||||
|
params.append(question_id)
|
||||||
|
|
||||||
|
duplicate = db.execute(duplicate_sql, params).fetchone()
|
||||||
|
|
||||||
|
if duplicate is not None:
|
||||||
|
return data, "Date already has a question."
|
||||||
|
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_account_form(form, admin):
|
||||||
|
username = form.get("username", "").strip()
|
||||||
|
current_password = form.get("current_password", "")
|
||||||
|
new_password = form.get("new_password", "")
|
||||||
|
confirm_password = form.get("confirm_password", "")
|
||||||
|
|
||||||
|
data = {"username": username}
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
return data, "Username is required."
|
||||||
|
|
||||||
|
if not check_password_hash(admin["password_hash"], current_password):
|
||||||
|
return data, "Current password is incorrect."
|
||||||
|
|
||||||
|
if new_password or confirm_password:
|
||||||
|
if len(new_password) < 8:
|
||||||
|
return data, "New password must be at least 8 characters."
|
||||||
|
|
||||||
|
if new_password != confirm_password:
|
||||||
|
return data, "New passwords do not match."
|
||||||
|
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
admin = db.execute(
|
||||||
|
"SELECT id, username, password_hash FROM admins WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if admin is not None and check_password_hash(admin["password_hash"], password):
|
||||||
|
session.clear()
|
||||||
|
session["admin"] = True
|
||||||
|
session["admin_username"] = admin["username"]
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
flash("Invalid username or password.")
|
||||||
|
|
||||||
|
return render_template("admin/login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/logout")
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("admin.login"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/dashboard")
|
||||||
|
@admin_required
|
||||||
|
def dashboard():
|
||||||
|
db = get_db()
|
||||||
|
questions = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, question, option_a, option_b, date
|
||||||
|
FROM questions
|
||||||
|
ORDER BY date DESC, id DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return render_template("admin/dashboard.html", questions=questions)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/account", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def account():
|
||||||
|
db = get_db()
|
||||||
|
admin = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, username, password_hash
|
||||||
|
FROM admins
|
||||||
|
WHERE username = ?
|
||||||
|
""",
|
||||||
|
(session.get("admin_username"),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if admin is None:
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("admin.login"))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
form_data = {"username": admin["username"]}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
form_data, error = validate_account_form(request.form, admin)
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
password_hash = admin["password_hash"]
|
||||||
|
new_password = request.form.get("new_password", "")
|
||||||
|
|
||||||
|
if new_password:
|
||||||
|
password_hash = generate_password_hash(new_password)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE admins
|
||||||
|
SET username = ?, password_hash = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(form_data["username"], password_hash, admin["id"]),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
error = "Username is already taken."
|
||||||
|
else:
|
||||||
|
session["admin_username"] = form_data["username"]
|
||||||
|
flash("Admin credentials updated.")
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/account.html",
|
||||||
|
admin=form_data,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/question/new", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def new_question():
|
||||||
|
question = None
|
||||||
|
error = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
data, error = validate_question_form(request.form)
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO questions (question, option_a, option_b, date)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
data["question"],
|
||||||
|
data["option_a"],
|
||||||
|
data["option_b"],
|
||||||
|
data["date"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
error = "Date already has a question."
|
||||||
|
|
||||||
|
question = data
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/question_form.html",
|
||||||
|
question=question,
|
||||||
|
error=error,
|
||||||
|
title="Create Question",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/question/edit/<int:question_id>", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def edit_question(question_id):
|
||||||
|
db = get_db()
|
||||||
|
question = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, question, option_a, option_b, date
|
||||||
|
FROM questions
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(question_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if question is None:
|
||||||
|
flash("Question not found.")
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
error = None
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
data, error = validate_question_form(request.form, question_id=question_id)
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
try:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE questions
|
||||||
|
SET question = ?, option_a = ?, option_b = ?, date = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
data["question"],
|
||||||
|
data["option_a"],
|
||||||
|
data["option_b"],
|
||||||
|
data["date"],
|
||||||
|
question_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
error = "Date already has a question."
|
||||||
|
|
||||||
|
question = data
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/question_form.html",
|
||||||
|
question=question,
|
||||||
|
error=error,
|
||||||
|
title="Edit Question",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/question/delete/<int:question_id>", methods=["GET", "POST"])
|
||||||
|
@admin_required
|
||||||
|
def delete_question(question_id):
|
||||||
|
db = get_db()
|
||||||
|
question = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, question, option_a, option_b, date
|
||||||
|
FROM questions
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(question_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if question is None:
|
||||||
|
flash("Question not found.")
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
db.execute("DELETE FROM votes WHERE question_id = ?", (question_id,))
|
||||||
|
db.execute("DELETE FROM questions WHERE id = ?", (question_id,))
|
||||||
|
db.commit()
|
||||||
|
return redirect(url_for("admin.dashboard"))
|
||||||
|
|
||||||
|
return render_template("admin/delete_confirm.html", question=question)
|
||||||
48
app/database.py
Normal file
48
app/database.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from flask import current_app, g
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
if "db" not in g:
|
||||||
|
g.db = sqlite3.connect(current_app.config["DATABASE"])
|
||||||
|
g.db.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
|
def close_db(error=None):
|
||||||
|
db = g.pop("db", None)
|
||||||
|
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
db.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
option_a TEXT NOT NULL,
|
||||||
|
option_b TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS votes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
question_id INTEGER,
|
||||||
|
vote TEXT NOT NULL,
|
||||||
|
voter_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
130
app/routes.py
Normal file
130
app/routes.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import hashlib
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from flask import Blueprint, redirect, render_template, request, url_for
|
||||||
|
|
||||||
|
from .database import get_db
|
||||||
|
|
||||||
|
bp = Blueprint("public", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_todays_question():
|
||||||
|
db = get_db()
|
||||||
|
today = date.today().isoformat()
|
||||||
|
|
||||||
|
return db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, question, option_a, option_b, date
|
||||||
|
FROM questions
|
||||||
|
WHERE date = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/")
|
||||||
|
def home():
|
||||||
|
question = get_todays_question()
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"public/index.html",
|
||||||
|
question=question,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/vote", methods=["POST"])
|
||||||
|
def vote():
|
||||||
|
qid = request.form.get("qid")
|
||||||
|
selected_vote = request.form.get("vote")
|
||||||
|
|
||||||
|
if not qid or selected_vote not in {"A", "B"}:
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
ip = request.remote_addr or ""
|
||||||
|
voter_hash = hashlib.sha256(ip.encode()).hexdigest()
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
question = get_todays_question()
|
||||||
|
|
||||||
|
if question is None or str(question["id"]) != str(qid):
|
||||||
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
|
existing = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM votes
|
||||||
|
WHERE voter_hash = ?
|
||||||
|
AND question_id = ?
|
||||||
|
""",
|
||||||
|
(voter_hash, qid),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO votes (question_id, vote, voter_hash)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
""",
|
||||||
|
(qid, selected_vote, voter_hash),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return redirect(url_for("public.results"))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/results")
|
||||||
|
def results():
|
||||||
|
question = get_todays_question()
|
||||||
|
|
||||||
|
if question is None:
|
||||||
|
return render_template(
|
||||||
|
"public/results.html",
|
||||||
|
question=None,
|
||||||
|
votes_a=0,
|
||||||
|
votes_b=0,
|
||||||
|
percent_a=0,
|
||||||
|
percent_b=0,
|
||||||
|
total=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
|
||||||
|
votes_a = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM votes
|
||||||
|
WHERE question_id = ?
|
||||||
|
AND vote = 'A'
|
||||||
|
""",
|
||||||
|
(question["id"],),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
votes_b = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM votes
|
||||||
|
WHERE question_id = ?
|
||||||
|
AND vote = 'B'
|
||||||
|
""",
|
||||||
|
(question["id"],),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
total = votes_a + votes_b
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
percent_a = 0
|
||||||
|
percent_b = 0
|
||||||
|
else:
|
||||||
|
percent_a = round(votes_a / total * 100)
|
||||||
|
percent_b = round(votes_b / total * 100)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"public/results.html",
|
||||||
|
question=question,
|
||||||
|
votes_a=votes_a,
|
||||||
|
votes_b=votes_b,
|
||||||
|
percent_a=percent_a,
|
||||||
|
percent_b=percent_b,
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
127
app/static/style.css
Normal file
127
app/static/style.css
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
body {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: auto;
|
||||||
|
padding-top: 60px;
|
||||||
|
text-align: center;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 250px;
|
||||||
|
height: 100px;
|
||||||
|
margin: 10px;
|
||||||
|
font-size: 22px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page .site-nav {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
padding: 0 20px 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header nav,
|
||||||
|
.row-actions,
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form input,
|
||||||
|
.admin-form textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-compact {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 12px 18px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: #b00020;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #b00020;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
color: #245900;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-row h2,
|
||||||
|
.question-row p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-date {
|
||||||
|
color: #666;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-header,
|
||||||
|
.question-row {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header nav,
|
||||||
|
.row-actions {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/templates/admin/account.html
Normal file
63
app/templates/admin/account.html
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Change Admin Credentials</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main class="page admin-page">
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="{{ url_for('admin.dashboard') }}">Dashboard</a>
|
||||||
|
<a href="{{ url_for('public.home') }}">Public Site</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Change Admin Credentials</h1>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="admin-form" method="POST">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
value="{{ admin.username }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<label for="current_password">Current Password</label>
|
||||||
|
<input
|
||||||
|
id="current_password"
|
||||||
|
name="current_password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<label for="new_password">New Password</label>
|
||||||
|
<input
|
||||||
|
id="new_password"
|
||||||
|
name="new_password"
|
||||||
|
type="password"
|
||||||
|
minlength="8"
|
||||||
|
>
|
||||||
|
|
||||||
|
<label for="confirm_password">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
type="password"
|
||||||
|
minlength="8"
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button-compact" type="submit">Save Credentials</button>
|
||||||
|
<a href="{{ url_for('admin.dashboard') }}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
app/templates/admin/dashboard.html
Normal file
46
app/templates/admin/dashboard.html
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Admin Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main class="page admin-page">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>Questions</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('public.home') }}">Public Site</a>
|
||||||
|
<a href="{{ url_for('admin.new_question') }}">Create Question</a>
|
||||||
|
<a href="{{ url_for('admin.account') }}">Change Credentials</a>
|
||||||
|
<a href="{{ url_for('admin.logout') }}">Log Out</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for message in get_flashed_messages() %}
|
||||||
|
<p class="notice">{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if questions %}
|
||||||
|
<div class="question-list">
|
||||||
|
{% for question in questions %}
|
||||||
|
<article class="question-row">
|
||||||
|
<div>
|
||||||
|
<p class="question-date">{{ question.date }}</p>
|
||||||
|
<h2>{{ question.question }}</h2>
|
||||||
|
<p>{{ question.option_a }} or {{ question.option_b }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<a href="{{ url_for('admin.edit_question', question_id=question.id) }}">Edit</a>
|
||||||
|
<a href="{{ url_for('admin.delete_question', question_id=question.id) }}">Delete</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p>No questions yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
app/templates/admin/delete_confirm.html
Normal file
24
app/templates/admin/delete_confirm.html
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Delete Question</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main class="page admin-page">
|
||||||
|
<h1>Delete Question</h1>
|
||||||
|
|
||||||
|
<p>Delete the question scheduled for {{ question.date }}?</p>
|
||||||
|
<h2>{{ question.question }}</h2>
|
||||||
|
|
||||||
|
<form class="admin-form" method="POST">
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button-compact danger" type="submit">Delete</button>
|
||||||
|
<a href="{{ url_for('admin.dashboard') }}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
app/templates/admin/login.html
Normal file
32
app/templates/admin/login.html
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Admin Login</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main class="page admin-page">
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="{{ url_for('public.home') }}">Public Site</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
|
||||||
|
{% for message in get_flashed_messages() %}
|
||||||
|
<p class="error">{{ message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<form class="admin-form" action="{{ url_for('admin.login') }}" method="POST">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input id="username" name="username" type="text" required>
|
||||||
|
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" required>
|
||||||
|
|
||||||
|
<button class="button-compact" type="submit">Log In</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
app/templates/admin/question_form.html
Normal file
55
app/templates/admin/question_form.html
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main class="page admin-page">
|
||||||
|
<h1>{{ title }}</h1>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<p class="error">{{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="admin-form" method="POST">
|
||||||
|
<label for="date">Date</label>
|
||||||
|
<input
|
||||||
|
id="date"
|
||||||
|
name="date"
|
||||||
|
type="date"
|
||||||
|
value="{{ question.date if question else '' }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<label for="question">Question</label>
|
||||||
|
<textarea id="question" name="question" rows="4" required>{{ question.question if question else '' }}</textarea>
|
||||||
|
|
||||||
|
<label for="option_a">Option A</label>
|
||||||
|
<input
|
||||||
|
id="option_a"
|
||||||
|
name="option_a"
|
||||||
|
type="text"
|
||||||
|
value="{{ question.option_a if question else '' }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<label for="option_b">Option B</label>
|
||||||
|
<input
|
||||||
|
id="option_b"
|
||||||
|
name="option_b"
|
||||||
|
type="text"
|
||||||
|
value="{{ question.option_b if question else '' }}"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button-compact" type="submit">Save</button>
|
||||||
|
<a href="{{ url_for('admin.dashboard') }}">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
49
app/templates/public/index.html
Normal file
49
app/templates/public/index.html
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Would You Rather</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main class="page">
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="{{ url_for('admin.dashboard') }}">Admin</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if question %}
|
||||||
|
<h1>{{ question.question }}</h1>
|
||||||
|
|
||||||
|
<form action="{{ url_for('public.vote') }}" method="POST">
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="qid"
|
||||||
|
value="{{ question.id }}"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="vote"
|
||||||
|
value="A"
|
||||||
|
>
|
||||||
|
{{ question.option_a }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="vote"
|
||||||
|
value="B"
|
||||||
|
>
|
||||||
|
{{ question.option_b }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<h1>No question is scheduled yet.</h1>
|
||||||
|
<p>Check back soon.</p>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
app/templates/public/results.html
Normal file
37
app/templates/public/results.html
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Results</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<main class="page">
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="{{ url_for('public.home') }}">Vote</a>
|
||||||
|
<a href="{{ url_for('admin.dashboard') }}">Admin</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{% if question %}
|
||||||
|
<h1>{{ question.question }}</h1>
|
||||||
|
|
||||||
|
<h2>{{ question.option_a }}</h2>
|
||||||
|
<p>{{ percent_a }}% ({{ votes_a }} votes)</p>
|
||||||
|
|
||||||
|
<h2>{{ question.option_b }}</h2>
|
||||||
|
<p>{{ percent_b }}% ({{ votes_b }} votes)</p>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<p>Total votes: {{ total }}</p>
|
||||||
|
|
||||||
|
<a href="{{ url_for('public.home') }}">Back</a>
|
||||||
|
{% else %}
|
||||||
|
<h1>No results yet.</h1>
|
||||||
|
<p>No question is scheduled yet.</p>
|
||||||
|
<a href="{{ url_for('public.home') }}">Back</a>
|
||||||
|
{% endif %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
init_db.py
39
init_db.py
@ -1,39 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
|
|
||||||
conn = sqlite3.connect("questions.db")
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS questions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
question TEXT NOT NULL,
|
|
||||||
option_a TEXT NOT NULL,
|
|
||||||
option_b TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL UNIQUE
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS votes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
question_id INTEGER,
|
|
||||||
vote TEXT NOT NULL,
|
|
||||||
voter_hash TEXT NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT OR IGNORE INTO questions
|
|
||||||
(question, option_a, option_b, date)
|
|
||||||
VALUES (
|
|
||||||
'Would you rather be blind or deaf?',
|
|
||||||
'Blind',
|
|
||||||
'Deaf',
|
|
||||||
'2030-01-01'
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
print("Database created.")
|
|
||||||
795
project-context.md
Normal file
795
project-context.md
Normal file
@ -0,0 +1,795 @@
|
|||||||
|
# Would You Rather Website - Project Context
|
||||||
|
|
||||||
|
## Project Goal
|
||||||
|
|
||||||
|
Build a lightweight "Would You Rather" website hosted on a Raspberry Pi 5.
|
||||||
|
|
||||||
|
Users visit the website, answer a daily question, and immediately see aggregated results after voting.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
> Would you rather be blind or deaf?
|
||||||
|
|
||||||
|
* Blind
|
||||||
|
* Deaf
|
||||||
|
|
||||||
|
After voting:
|
||||||
|
|
||||||
|
* Blind: 62%
|
||||||
|
* Deaf: 38%
|
||||||
|
|
||||||
|
Total votes: 523
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Current Development Strategy
|
||||||
|
|
||||||
|
Development is being done on a laptop first.
|
||||||
|
|
||||||
|
Reasons:
|
||||||
|
|
||||||
|
* Easier coding workflow
|
||||||
|
* Faster testing
|
||||||
|
* Git integration from day one
|
||||||
|
* Easier version control
|
||||||
|
* Safer than developing directly on the Raspberry Pi
|
||||||
|
|
||||||
|
Deployment strategy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Laptop
|
||||||
|
↓
|
||||||
|
Git
|
||||||
|
↓
|
||||||
|
GitHub
|
||||||
|
↓
|
||||||
|
Raspberry Pi
|
||||||
|
↓
|
||||||
|
git pull
|
||||||
|
↓
|
||||||
|
Production
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
* Python
|
||||||
|
* Flask
|
||||||
|
* Flask application factory pattern
|
||||||
|
* Flask blueprints for public and admin routes
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
* SQLite
|
||||||
|
* Local development database path: `instance/questions.db`
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
* HTML
|
||||||
|
* CSS
|
||||||
|
* Jinja2 templates
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
* Flask sessions
|
||||||
|
* Werkzeug password hashing for admin passwords
|
||||||
|
|
||||||
|
## Version Control
|
||||||
|
|
||||||
|
* Git
|
||||||
|
* GitHub
|
||||||
|
|
||||||
|
## Future Production
|
||||||
|
|
||||||
|
* Gunicorn
|
||||||
|
* Systemd
|
||||||
|
* Nginx
|
||||||
|
* HTTPS with Let's Encrypt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Current Local Project Structure
|
||||||
|
|
||||||
|
The project has been refactored out of a single-file Flask app.
|
||||||
|
|
||||||
|
Current structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
would-you-rather/
|
||||||
|
│
|
||||||
|
├── .venv/
|
||||||
|
├── .gitignore
|
||||||
|
├── app.py
|
||||||
|
├── run.py
|
||||||
|
├── requirements.txt
|
||||||
|
├── project-context.md
|
||||||
|
│
|
||||||
|
├── app/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── admin.py
|
||||||
|
│ ├── database.py
|
||||||
|
│ ├── routes.py
|
||||||
|
│ │
|
||||||
|
│ ├── templates/
|
||||||
|
│ │ ├── public/
|
||||||
|
│ │ │ ├── index.html
|
||||||
|
│ │ │ └── results.html
|
||||||
|
│ │ │
|
||||||
|
│ │ └── admin/
|
||||||
|
│ │ ├── dashboard.html
|
||||||
|
│ │ ├── delete_confirm.html
|
||||||
|
│ │ ├── login.html
|
||||||
|
│ │ └── question_form.html
|
||||||
|
│ │
|
||||||
|
│ └── static/
|
||||||
|
│ └── style.css
|
||||||
|
│
|
||||||
|
├── scripts/
|
||||||
|
│ └── init_db.py
|
||||||
|
│
|
||||||
|
├── tests/
|
||||||
|
│ └── test_app.py
|
||||||
|
│
|
||||||
|
└── instance/
|
||||||
|
└── questions.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
* `app.py` remains as a compatibility entrypoint.
|
||||||
|
* `run.py` is the preferred local development entrypoint.
|
||||||
|
* `instance/` is ignored by Git.
|
||||||
|
* Root-level `questions.db` is ignored and is no longer the active app database.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Current Application Architecture
|
||||||
|
|
||||||
|
## App Factory
|
||||||
|
|
||||||
|
File:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/__init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
* Creates the Flask app with `create_app()`.
|
||||||
|
* Configures `SECRET_KEY`.
|
||||||
|
* Configures SQLite database path.
|
||||||
|
* Ensures the `instance/` directory exists.
|
||||||
|
* Registers public and admin blueprints.
|
||||||
|
* Registers database teardown behavior.
|
||||||
|
|
||||||
|
Important config:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SECRET_KEY = os.environ.get("WYR_SECRET_KEY", "dev-only-change-me")
|
||||||
|
DATABASE = instance/questions.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Production must set a real `WYR_SECRET_KEY`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Helpers
|
||||||
|
|
||||||
|
File:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/database.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
* Opens SQLite connections with `get_db()`.
|
||||||
|
* Uses `sqlite3.Row` so routes/templates can access fields by name.
|
||||||
|
* Closes request-scoped connections with `close_db()`.
|
||||||
|
* Creates all tables with `init_db()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Public Routes
|
||||||
|
|
||||||
|
File:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/routes.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/
|
||||||
|
/vote
|
||||||
|
/results
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
* `/` displays the question scheduled for today's date.
|
||||||
|
* `/vote` accepts POSTed `qid` and `vote`.
|
||||||
|
* `/vote` only accepts votes for today's scheduled question.
|
||||||
|
* `/results` displays today's vote counts, percentages, and total votes.
|
||||||
|
* Empty states are shown if no question exists for today.
|
||||||
|
* Duplicate voting is still blocked by hashing the request IP address.
|
||||||
|
|
||||||
|
Current duplicate prevention is temporary.
|
||||||
|
|
||||||
|
Future plan:
|
||||||
|
|
||||||
|
* Use cookies or session identifiers instead of IP hashing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Current Database Design
|
||||||
|
|
||||||
|
## Questions Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE questions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
option_a TEXT NOT NULL,
|
||||||
|
option_b TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Stores scheduled daily questions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Votes Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE votes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
question_id INTEGER,
|
||||||
|
vote TEXT NOT NULL,
|
||||||
|
voter_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Stores user votes.
|
||||||
|
|
||||||
|
Stored values:
|
||||||
|
|
||||||
|
* `question_id`
|
||||||
|
* selected vote, currently `A` or `B`
|
||||||
|
* `voter_hash`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admins Table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE admins (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
|
||||||
|
Stores admin login accounts.
|
||||||
|
|
||||||
|
Passwords must never be stored in plain text.
|
||||||
|
|
||||||
|
Current implementation uses:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Admin Dashboard V1
|
||||||
|
|
||||||
|
Admin functionality has been implemented.
|
||||||
|
|
||||||
|
File:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/admin.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/admin/login
|
||||||
|
/admin/logout
|
||||||
|
/admin/account
|
||||||
|
/admin/dashboard
|
||||||
|
/admin/question/new
|
||||||
|
/admin/question/edit/<id>
|
||||||
|
/admin/question/delete/<id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Implemented behavior:
|
||||||
|
|
||||||
|
* Admin login with username/password.
|
||||||
|
* Password verification with Werkzeug password hashing.
|
||||||
|
* Successful login stores `session["admin"] = True`.
|
||||||
|
* Protected admin pages use `@admin_required`.
|
||||||
|
* Unauthenticated users are redirected to `/admin/login`.
|
||||||
|
* Admins can change their username and password from `/admin/account`.
|
||||||
|
* Changing credentials requires the current password.
|
||||||
|
* Dashboard lists all questions ordered by date descending.
|
||||||
|
* Admins can create questions.
|
||||||
|
* Admins can edit questions.
|
||||||
|
* Admins can delete questions.
|
||||||
|
* Delete also removes votes for that question.
|
||||||
|
* Duplicate question dates are blocked.
|
||||||
|
* Required fields are validated.
|
||||||
|
|
||||||
|
Dashboard V1 is intentionally simple.
|
||||||
|
|
||||||
|
No JavaScript calendar is implemented yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Local Development Setup
|
||||||
|
|
||||||
|
## Install Dependencies
|
||||||
|
|
||||||
|
Use the virtual environment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Or run commands directly through `.venv/bin/python`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initialize the Database
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python scripts/init_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
|
||||||
|
```text
|
||||||
|
instance/questions.db
|
||||||
|
```
|
||||||
|
|
||||||
|
It also seeds:
|
||||||
|
|
||||||
|
* one sample question
|
||||||
|
* one local admin account
|
||||||
|
|
||||||
|
Default local admin login:
|
||||||
|
|
||||||
|
```text
|
||||||
|
username: admin
|
||||||
|
password: admin
|
||||||
|
```
|
||||||
|
|
||||||
|
To override the seeded admin account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WYR_ADMIN_USERNAME=myname WYR_ADMIN_PASSWORD=mypassword .venv/bin/python scripts/init_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If the admin user already exists, the script will not overwrite that password.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run the Site Locally
|
||||||
|
|
||||||
|
Preferred command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
Admin login:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5000/admin/login
|
||||||
|
```
|
||||||
|
|
||||||
|
Default local login:
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin / admin
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Run Tests
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m unittest discover -s tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Current automated tests cover:
|
||||||
|
|
||||||
|
* public empty state
|
||||||
|
* voting flow
|
||||||
|
* duplicate vote prevention
|
||||||
|
* admin login success
|
||||||
|
* admin login failure
|
||||||
|
* admin credential changes
|
||||||
|
* protected dashboard redirect
|
||||||
|
* duplicate date validation when creating questions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Manual Testing Checklist
|
||||||
|
|
||||||
|
## Public Site
|
||||||
|
|
||||||
|
1. Start the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Confirm the sample question appears if it is scheduled for today's date.
|
||||||
|
|
||||||
|
4. Click one answer.
|
||||||
|
|
||||||
|
5. Confirm the site redirects to `/results`.
|
||||||
|
|
||||||
|
6. Confirm results show percentages and total votes.
|
||||||
|
|
||||||
|
7. Vote again from the same browser/IP.
|
||||||
|
|
||||||
|
8. Confirm the duplicate vote does not increase the vote count.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Site
|
||||||
|
|
||||||
|
1. Open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:5000/admin/login
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Log in with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin / admin
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Confirm the dashboard appears.
|
||||||
|
|
||||||
|
4. Click `Change Credentials`.
|
||||||
|
|
||||||
|
5. Confirm changing credentials requires the current password.
|
||||||
|
|
||||||
|
6. Save new credentials, log out, and confirm the new login works.
|
||||||
|
|
||||||
|
7. Click `Create Question`.
|
||||||
|
|
||||||
|
8. Add today's date or a future date, question, option A, and option B.
|
||||||
|
|
||||||
|
9. Save and confirm the new question appears in the dashboard.
|
||||||
|
|
||||||
|
10. Edit the question and confirm changes persist.
|
||||||
|
|
||||||
|
11. Try creating another question with the same date.
|
||||||
|
|
||||||
|
12. Confirm this error appears:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Date already has a question.
|
||||||
|
```
|
||||||
|
|
||||||
|
13. Delete a question and confirm it disappears from the dashboard.
|
||||||
|
|
||||||
|
14. Log out.
|
||||||
|
|
||||||
|
15. Visit `/admin/dashboard` while logged out and confirm it redirects to `/admin/login`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Development Notes
|
||||||
|
|
||||||
|
## Current Compatibility Entrypoints
|
||||||
|
|
||||||
|
Preferred local command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Compatibility command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Both create the app through:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app import create_app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Ignore Rules
|
||||||
|
|
||||||
|
The following local-only files/directories are ignored:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
questions.db
|
||||||
|
instance/
|
||||||
|
.env
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
```
|
||||||
|
|
||||||
|
The active SQLite database is intentionally not committed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Future Admin Features
|
||||||
|
|
||||||
|
## Dashboard Version 2
|
||||||
|
|
||||||
|
Use a proper calendar interface.
|
||||||
|
|
||||||
|
Possible library:
|
||||||
|
|
||||||
|
* FullCalendar
|
||||||
|
|
||||||
|
Desired behavior:
|
||||||
|
|
||||||
|
```text
|
||||||
|
June 2026
|
||||||
|
|
||||||
|
13 Question
|
||||||
|
14 Question
|
||||||
|
15 Empty
|
||||||
|
16 Question
|
||||||
|
```
|
||||||
|
|
||||||
|
Clicking a date:
|
||||||
|
|
||||||
|
* opens edit view if the date has a question
|
||||||
|
* opens create view if the date is empty
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
Search questions by:
|
||||||
|
|
||||||
|
* date
|
||||||
|
* question text
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Statistics View
|
||||||
|
|
||||||
|
Admin dashboard should eventually show:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Question
|
||||||
|
|
||||||
|
Blind: 142
|
||||||
|
Deaf: 98
|
||||||
|
|
||||||
|
Total: 240
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful for engagement analytics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Future Public Features
|
||||||
|
|
||||||
|
## Better Duplicate Vote Prevention
|
||||||
|
|
||||||
|
Replace IP hashing with:
|
||||||
|
|
||||||
|
* cookies
|
||||||
|
* sessions
|
||||||
|
* or lightweight anonymous visitor IDs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archive
|
||||||
|
|
||||||
|
Browse historical questions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Discussion under questions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Country Statistics
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Denmark
|
||||||
|
|
||||||
|
Blind: 65%
|
||||||
|
Deaf: 35%
|
||||||
|
|
||||||
|
United States
|
||||||
|
|
||||||
|
Blind: 52%
|
||||||
|
Deaf: 48%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Generated Images
|
||||||
|
|
||||||
|
One image per question.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Would you rather live underwater or in space?
|
||||||
|
```
|
||||||
|
|
||||||
|
with generated artwork.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Accounts
|
||||||
|
|
||||||
|
Optional.
|
||||||
|
|
||||||
|
Users can:
|
||||||
|
|
||||||
|
* save history
|
||||||
|
* track participation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Future AI Features
|
||||||
|
|
||||||
|
Not a priority yet.
|
||||||
|
|
||||||
|
Planned later.
|
||||||
|
|
||||||
|
Possible route:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Generate next 30 questions
|
||||||
|
```
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
|
||||||
|
1. AI generates questions.
|
||||||
|
2. Questions fill empty dates.
|
||||||
|
3. Admin reviews.
|
||||||
|
4. Admin approves.
|
||||||
|
|
||||||
|
Potential providers:
|
||||||
|
|
||||||
|
* OpenAI
|
||||||
|
* OpenRouter
|
||||||
|
* Mistral
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Future Raspberry Pi Deployment
|
||||||
|
|
||||||
|
Not currently being worked on.
|
||||||
|
|
||||||
|
Planned deployment stack:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Internet
|
||||||
|
↓
|
||||||
|
Cloudflare
|
||||||
|
↓
|
||||||
|
Nginx
|
||||||
|
↓
|
||||||
|
Gunicorn
|
||||||
|
↓
|
||||||
|
Flask
|
||||||
|
↓
|
||||||
|
SQLite
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deployment Workflow
|
||||||
|
|
||||||
|
On laptop:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feature"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
On Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
sudo systemctl restart wyr
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Immediate Roadmap
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
Local refactor and Admin Dashboard V1 are implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
|
||||||
|
Polish the admin flow and public UI:
|
||||||
|
|
||||||
|
* improve layout
|
||||||
|
* add clearer success messages
|
||||||
|
* add better vote duplicate handling
|
||||||
|
* improve the empty state when no question is scheduled for today
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After Admin Polish
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
* calendar view
|
||||||
|
* question scheduling by date
|
||||||
|
* admin statistics
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After Calendar
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
* AI-assisted question generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Long-Term Goal
|
||||||
|
|
||||||
|
A polished daily "Would You Rather" website that:
|
||||||
|
|
||||||
|
* runs on a Raspberry Pi 5
|
||||||
|
* uses a custom domain
|
||||||
|
* supports HTTPS
|
||||||
|
* allows browser-based question management
|
||||||
|
* supports AI-assisted content generation
|
||||||
|
* scales beyond the initial MVP
|
||||||
8
run.py
Normal file
8
run.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True)
|
||||||
63
scripts/init_db.py
Normal file
63
scripts/init_db.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.database import get_db, init_db
|
||||||
|
|
||||||
|
|
||||||
|
def seed_question(db):
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO questions (question, option_a, option_b, date)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"Would you rather be blind or deaf?",
|
||||||
|
"Blind",
|
||||||
|
"Deaf",
|
||||||
|
date.today().isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def seed_admin(db):
|
||||||
|
username = os.environ.get("WYR_ADMIN_USERNAME", "admin")
|
||||||
|
password = os.environ.get("WYR_ADMIN_PASSWORD", "admin")
|
||||||
|
|
||||||
|
existing = db.execute(
|
||||||
|
"SELECT id FROM admins WHERE username = ?",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO admins (username, password_hash)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
(username, generate_password_hash(password)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
init_db()
|
||||||
|
db = get_db()
|
||||||
|
seed_question(db)
|
||||||
|
username, password = seed_admin(db)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print("Database created.")
|
||||||
|
|
||||||
|
if username == "admin" and password == "admin":
|
||||||
|
print("Default admin login: admin / admin")
|
||||||
@ -1,19 +0,0 @@
|
|||||||
body {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: auto;
|
|
||||||
padding-top: 60px;
|
|
||||||
text-align: center;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 250px;
|
|
||||||
height: 100px;
|
|
||||||
margin: 10px;
|
|
||||||
font-size: 22px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Would You Rather</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>{{ question[1] }}</h1>
|
|
||||||
|
|
||||||
<form action="/vote" method="POST">
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="hidden"
|
|
||||||
name="qid"
|
|
||||||
value="{{ question[0] }}"
|
|
||||||
>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
name="vote"
|
|
||||||
value="A"
|
|
||||||
>
|
|
||||||
{{ question[2] }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
name="vote"
|
|
||||||
value="B"
|
|
||||||
>
|
|
||||||
{{ question[3] }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Results</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>{{ question[1] }}</h1>
|
|
||||||
|
|
||||||
<h2>{{ question[2] }}</h2>
|
|
||||||
<p>{{ percent_a }}% ({{ votes_a }} votes)</p>
|
|
||||||
|
|
||||||
<h2>{{ question[3] }}</h2>
|
|
||||||
<p>{{ percent_b }}% ({{ votes_b }} votes)</p>
|
|
||||||
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<p>Total votes: {{ total }}</p>
|
|
||||||
|
|
||||||
<a href="/">Back</a>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
186
tests/test_app.py
Normal file
186
tests/test_app.py
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
from app import create_app
|
||||||
|
from app.database import get_db, init_db
|
||||||
|
|
||||||
|
|
||||||
|
class AppTestCase(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.db_fd, self.db_path = tempfile.mkstemp()
|
||||||
|
self.app = create_app(
|
||||||
|
{
|
||||||
|
"TESTING": True,
|
||||||
|
"DATABASE": self.db_path,
|
||||||
|
"SECRET_KEY": "test-secret",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
init_db()
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO admins (username, password_hash)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""",
|
||||||
|
("admin", generate_password_hash("password")),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
os.close(self.db_fd)
|
||||||
|
os.unlink(self.db_path)
|
||||||
|
|
||||||
|
def seed_question(self, question_date=None):
|
||||||
|
if question_date is None:
|
||||||
|
question_date = date.today().isoformat()
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
db = get_db()
|
||||||
|
db.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO questions (question, option_a, option_b, date)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
("Would you rather test A or test B?", "Test A", "Test B", question_date),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
return self.client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": "admin", "password": "password"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_public_empty_state(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b"No question is scheduled yet.", response.data)
|
||||||
|
|
||||||
|
def test_voting_flow_blocks_duplicate_vote(self):
|
||||||
|
self.seed_question()
|
||||||
|
|
||||||
|
first = self.client.post("/vote", data={"qid": "1", "vote": "A"})
|
||||||
|
second = self.client.post("/vote", data={"qid": "1", "vote": "A"})
|
||||||
|
|
||||||
|
self.assertEqual(first.status_code, 302)
|
||||||
|
self.assertEqual(second.status_code, 302)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
count = get_db().execute("SELECT COUNT(*) FROM votes").fetchone()[0]
|
||||||
|
|
||||||
|
self.assertEqual(count, 1)
|
||||||
|
|
||||||
|
def test_homepage_uses_todays_question_only(self):
|
||||||
|
self.seed_question("2030-01-01")
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
self.assertIn(b"No question is scheduled yet.", response.data)
|
||||||
|
|
||||||
|
self.seed_question()
|
||||||
|
response = self.client.get("/")
|
||||||
|
|
||||||
|
self.assertIn(b"Would you rather test A or test B?", response.data)
|
||||||
|
|
||||||
|
def test_vote_rejects_non_today_question(self):
|
||||||
|
self.seed_question("2030-01-01")
|
||||||
|
|
||||||
|
response = self.client.post("/vote", data={"qid": "1", "vote": "A"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
count = get_db().execute("SELECT COUNT(*) FROM votes").fetchone()[0]
|
||||||
|
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
|
||||||
|
def test_admin_login_success_and_failure(self):
|
||||||
|
failed = self.client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": "admin", "password": "wrong"},
|
||||||
|
)
|
||||||
|
self.assertIn(b"Invalid username or password.", failed.data)
|
||||||
|
|
||||||
|
success = self.login()
|
||||||
|
self.assertIn(b"Questions", success.data)
|
||||||
|
|
||||||
|
def test_protected_route_redirects_to_login(self):
|
||||||
|
response = self.client.get("/admin/dashboard")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/admin/login", response.headers["Location"])
|
||||||
|
|
||||||
|
def test_question_create_duplicate_date_failure(self):
|
||||||
|
self.seed_question()
|
||||||
|
self.login()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/admin/question/new",
|
||||||
|
data={
|
||||||
|
"date": date.today().isoformat(),
|
||||||
|
"question": "Another question?",
|
||||||
|
"option_a": "A",
|
||||||
|
"option_b": "B",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(b"Date already has a question.", response.data)
|
||||||
|
|
||||||
|
def test_admin_can_change_credentials(self):
|
||||||
|
self.login()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/admin/account",
|
||||||
|
data={
|
||||||
|
"username": "newadmin",
|
||||||
|
"current_password": "password",
|
||||||
|
"new_password": "new-password",
|
||||||
|
"confirm_password": "new-password",
|
||||||
|
},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(b"Admin credentials updated.", response.data)
|
||||||
|
|
||||||
|
self.client.get("/admin/logout")
|
||||||
|
|
||||||
|
old_login = self.client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": "admin", "password": "password"},
|
||||||
|
)
|
||||||
|
self.assertIn(b"Invalid username or password.", old_login.data)
|
||||||
|
|
||||||
|
new_login = self.client.post(
|
||||||
|
"/admin/login",
|
||||||
|
data={"username": "newadmin", "password": "new-password"},
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
self.assertIn(b"Questions", new_login.data)
|
||||||
|
|
||||||
|
def test_admin_account_requires_current_password(self):
|
||||||
|
self.login()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
"/admin/account",
|
||||||
|
data={
|
||||||
|
"username": "newadmin",
|
||||||
|
"current_password": "wrong",
|
||||||
|
"new_password": "new-password",
|
||||||
|
"confirm_password": "new-password",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(b"Current password is incorrect.", response.data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Reference in New Issue
Block a user