diff --git a/.gitignore b/.gitignore index 29b9070..51ebf07 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.pyc questions.db +instance/ .env .vscode/ .idea/ diff --git a/app.py b/app.py index 97522e4..db99a5d 100644 --- a/app.py +++ b/app.py @@ -1,135 +1,8 @@ -from flask import Flask, render_template, request, redirect -from datetime import date -import sqlite3 -import hashlib - -app = Flask(__name__) - -DB = "questions.db" +from app import create_app -def get_db(): - 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 - ) +app = create_app() if __name__ == "__main__": - app.run(debug=True) \ No newline at end of file + app.run(debug=True) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..297285d --- /dev/null +++ b/app/__init__.py @@ -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 diff --git a/app/admin.py b/app/admin.py new file mode 100644 index 0000000..8d90b82 --- /dev/null +++ b/app/admin.py @@ -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/", 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/", 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) diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..f3b3a4e --- /dev/null +++ b/app/database.py @@ -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() diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..f3ddce1 --- /dev/null +++ b/app/routes.py @@ -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, + ) diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..e87faee --- /dev/null +++ b/app/static/style.css @@ -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; + } +} diff --git a/app/templates/admin/account.html b/app/templates/admin/account.html new file mode 100644 index 0000000..a7bce33 --- /dev/null +++ b/app/templates/admin/account.html @@ -0,0 +1,63 @@ + + + + Change Admin Credentials + + + + +
+ + +

Change Admin Credentials

+ + {% if error %} +

{{ error }}

+ {% endif %} + +
+ + + + + + + + + + + + +
+ + Cancel +
+
+
+ + + diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..bb20b2e --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,46 @@ + + + + Admin Dashboard + + + + +
+ + + {% for message in get_flashed_messages() %} +

{{ message }}

+ {% endfor %} + + {% if questions %} +
+ {% for question in questions %} +
+
+

{{ question.date }}

+

{{ question.question }}

+

{{ question.option_a }} or {{ question.option_b }}

+
+
+ Edit + Delete +
+
+ {% endfor %} +
+ {% else %} +

No questions yet.

+ {% endif %} +
+ + + diff --git a/app/templates/admin/delete_confirm.html b/app/templates/admin/delete_confirm.html new file mode 100644 index 0000000..5b5b287 --- /dev/null +++ b/app/templates/admin/delete_confirm.html @@ -0,0 +1,24 @@ + + + + Delete Question + + + + +
+

Delete Question

+ +

Delete the question scheduled for {{ question.date }}?

+

{{ question.question }}

+ +
+
+ + Cancel +
+
+
+ + + diff --git a/app/templates/admin/login.html b/app/templates/admin/login.html new file mode 100644 index 0000000..31d30a4 --- /dev/null +++ b/app/templates/admin/login.html @@ -0,0 +1,32 @@ + + + + Admin Login + + + + +
+ + +

Admin Login

+ + {% for message in get_flashed_messages() %} +

{{ message }}

+ {% endfor %} + +
+ + + + + + + +
+
+ + + diff --git a/app/templates/admin/question_form.html b/app/templates/admin/question_form.html new file mode 100644 index 0000000..ab184a2 --- /dev/null +++ b/app/templates/admin/question_form.html @@ -0,0 +1,55 @@ + + + + {{ title }} + + + + +
+

{{ title }}

+ + {% if error %} +

{{ error }}

+ {% endif %} + +
+ + + + + + + + + + + + +
+ + Cancel +
+
+
+ + + diff --git a/app/templates/public/index.html b/app/templates/public/index.html new file mode 100644 index 0000000..24a83f1 --- /dev/null +++ b/app/templates/public/index.html @@ -0,0 +1,49 @@ + + + + Would You Rather + + + + +
+ + + {% if question %} +

{{ question.question }}

+ +
+ + + + + + + +
+ {% else %} +

No question is scheduled yet.

+

Check back soon.

+ {% endif %} +
+ + + diff --git a/app/templates/public/results.html b/app/templates/public/results.html new file mode 100644 index 0000000..36fa20c --- /dev/null +++ b/app/templates/public/results.html @@ -0,0 +1,37 @@ + + + + Results + + + + +
+ + + {% if question %} +

{{ question.question }}

+ +

{{ question.option_a }}

+

{{ percent_a }}% ({{ votes_a }} votes)

+ +

{{ question.option_b }}

+

{{ percent_b }}% ({{ votes_b }} votes)

+ +
+ +

Total votes: {{ total }}

+ + Back + {% else %} +

No results yet.

+

No question is scheduled yet.

+ Back + {% endif %} +
+ + + diff --git a/init_db.py b/init_db.py deleted file mode 100644 index 570c59e..0000000 --- a/init_db.py +++ /dev/null @@ -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.") \ No newline at end of file diff --git a/project-context.md b/project-context.md new file mode 100644 index 0000000..2efc824 --- /dev/null +++ b/project-context.md @@ -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/ +/admin/question/delete/ +``` + +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 diff --git a/run.py b/run.py new file mode 100644 index 0000000..db99a5d --- /dev/null +++ b/run.py @@ -0,0 +1,8 @@ +from app import create_app + + +app = create_app() + + +if __name__ == "__main__": + app.run(debug=True) diff --git a/scripts/init_db.py b/scripts/init_db.py new file mode 100644 index 0000000..66cec67 --- /dev/null +++ b/scripts/init_db.py @@ -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") diff --git a/static/style.css b/static/style.css deleted file mode 100644 index 38881cf..0000000 --- a/static/style.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html deleted file mode 100644 index 5283c12..0000000 --- a/templates/index.html +++ /dev/null @@ -1,38 +0,0 @@ - - - - Would You Rather - - - - -

{{ question[1] }}

- -
- - - - - - - -
- - - \ No newline at end of file diff --git a/templates/results.html b/templates/results.html deleted file mode 100644 index 2066140..0000000 --- a/templates/results.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - Results - - - - -

{{ question[1] }}

- -

{{ question[2] }}

-

{{ percent_a }}% ({{ votes_a }} votes)

- -

{{ question[3] }}

-

{{ percent_b }}% ({{ votes_b }} votes)

- -
- -

Total votes: {{ total }}

- -Back - - - \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..4a30c70 --- /dev/null +++ b/tests/test_app.py @@ -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()