changed vote limit to cookie-based rather than IP-based
This commit is contained in:
parent
a484f13e7d
commit
8dbddf8fb8
17
app/admin.py
17
app/admin.py
@ -1,5 +1,6 @@
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@ -84,6 +85,13 @@ def validate_account_form(form, admin):
|
|||||||
return data, None
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def question_content_changed(question, data):
|
||||||
|
return any(
|
||||||
|
question[field] != data[field]
|
||||||
|
for field in ("question", "option_a", "option_b", "date")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/login", methods=["GET", "POST"])
|
@bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@ -246,6 +254,11 @@ def edit_question(question_id):
|
|||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
try:
|
try:
|
||||||
|
should_clear_votes = (
|
||||||
|
question["date"] == date.today().isoformat()
|
||||||
|
and question_content_changed(question, data)
|
||||||
|
)
|
||||||
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE questions
|
UPDATE questions
|
||||||
@ -260,6 +273,10 @@ def edit_question(question_id):
|
|||||||
question_id,
|
question_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if should_clear_votes:
|
||||||
|
db.execute("DELETE FROM votes WHERE question_id = ?", (question_id,))
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return redirect(url_for("admin.dashboard"))
|
return redirect(url_for("admin.dashboard"))
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
from datetime import date
|
from datetime import date, datetime, time, timedelta
|
||||||
|
|
||||||
from flask import Blueprint, redirect, render_template, request, url_for
|
from flask import Blueprint, redirect, render_template, request, url_for
|
||||||
|
|
||||||
@ -7,6 +7,8 @@ from .database import get_db
|
|||||||
|
|
||||||
bp = Blueprint("public", __name__)
|
bp = Blueprint("public", __name__)
|
||||||
|
|
||||||
|
VOTE_COOKIE_NAME = "wyr_daily_vote"
|
||||||
|
|
||||||
|
|
||||||
def get_todays_question():
|
def get_todays_question():
|
||||||
db = get_db()
|
db = get_db()
|
||||||
@ -23,6 +25,46 @@ def get_todays_question():
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def get_question_version(question):
|
||||||
|
parts = (
|
||||||
|
str(question["id"]),
|
||||||
|
question["date"],
|
||||||
|
question["question"],
|
||||||
|
question["option_a"],
|
||||||
|
question["option_b"],
|
||||||
|
)
|
||||||
|
fingerprint = "\x1f".join(parts)
|
||||||
|
|
||||||
|
return hashlib.sha256(fingerprint.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def get_vote_cookie_value(question):
|
||||||
|
return f"{question['date']}:{question['id']}:{get_question_version(question)}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_seconds_until_tomorrow():
|
||||||
|
now = datetime.now()
|
||||||
|
tomorrow = datetime.combine(date.today() + timedelta(days=1), time.min)
|
||||||
|
|
||||||
|
return max(1, int((tomorrow - now).total_seconds()))
|
||||||
|
|
||||||
|
|
||||||
|
def has_voted_for_question(question):
|
||||||
|
return request.cookies.get(VOTE_COOKIE_NAME) == get_vote_cookie_value(question)
|
||||||
|
|
||||||
|
|
||||||
|
def set_vote_cookie(response, question):
|
||||||
|
response.set_cookie(
|
||||||
|
VOTE_COOKIE_NAME,
|
||||||
|
get_vote_cookie_value(question),
|
||||||
|
max_age=get_seconds_until_tomorrow(),
|
||||||
|
httponly=True,
|
||||||
|
samesite="Lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/")
|
@bp.route("/")
|
||||||
def home():
|
def home():
|
||||||
question = get_todays_question()
|
question = get_todays_question()
|
||||||
@ -41,36 +83,26 @@ def vote():
|
|||||||
if not qid or selected_vote not in {"A", "B"}:
|
if not qid or selected_vote not in {"A", "B"}:
|
||||||
return redirect(url_for("public.home"))
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
ip = request.remote_addr or ""
|
|
||||||
voter_hash = hashlib.sha256(ip.encode()).hexdigest()
|
|
||||||
|
|
||||||
db = get_db()
|
db = get_db()
|
||||||
question = get_todays_question()
|
question = get_todays_question()
|
||||||
|
|
||||||
if question is None or str(question["id"]) != str(qid):
|
if question is None or str(question["id"]) != str(qid):
|
||||||
return redirect(url_for("public.home"))
|
return redirect(url_for("public.home"))
|
||||||
|
|
||||||
existing = db.execute(
|
if has_voted_for_question(question):
|
||||||
"""
|
return redirect(url_for("public.results"))
|
||||||
SELECT id
|
|
||||||
FROM votes
|
|
||||||
WHERE voter_hash = ?
|
|
||||||
AND question_id = ?
|
|
||||||
""",
|
|
||||||
(voter_hash, qid),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if existing is None:
|
|
||||||
db.execute(
|
db.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO votes (question_id, vote, voter_hash)
|
INSERT INTO votes (question_id, vote, voter_hash)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(qid, selected_vote, voter_hash),
|
(qid, selected_vote, "cookie"),
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return redirect(url_for("public.results"))
|
response = redirect(url_for("public.results"))
|
||||||
|
return set_vote_cookie(response, question)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/results")
|
@bp.route("/results")
|
||||||
|
|||||||
@ -80,6 +80,35 @@ class AppTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(count, 1)
|
self.assertEqual(count, 1)
|
||||||
|
|
||||||
|
def test_new_browser_can_vote_without_cookie(self):
|
||||||
|
self.seed_question()
|
||||||
|
|
||||||
|
first = self.client.post("/vote", data={"qid": "1", "vote": "A"})
|
||||||
|
other_client = self.app.test_client()
|
||||||
|
second = other_client.post("/vote", data={"qid": "1", "vote": "B"})
|
||||||
|
|
||||||
|
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, 2)
|
||||||
|
|
||||||
|
def test_invalid_vote_does_not_set_cookie(self):
|
||||||
|
response = self.client.post("/vote", data={"qid": "", "vote": "A"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertNotIn("wyr_daily_vote", response.headers.get("Set-Cookie", ""))
|
||||||
|
|
||||||
|
def test_non_today_vote_does_not_set_cookie(self):
|
||||||
|
self.seed_question("2030-01-01")
|
||||||
|
|
||||||
|
response = self.client.post("/vote", data={"qid": "1", "vote": "A"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertNotIn("wyr_daily_vote", response.headers.get("Set-Cookie", ""))
|
||||||
|
|
||||||
def test_homepage_uses_todays_question_only(self):
|
def test_homepage_uses_todays_question_only(self):
|
||||||
self.seed_question("2030-01-01")
|
self.seed_question("2030-01-01")
|
||||||
response = self.client.get("/")
|
response = self.client.get("/")
|
||||||
@ -103,6 +132,42 @@ class AppTestCase(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(count, 0)
|
self.assertEqual(count, 0)
|
||||||
|
|
||||||
|
def test_editing_todays_question_clears_votes_and_allows_new_vote(self):
|
||||||
|
self.seed_question()
|
||||||
|
self.client.post("/vote", data={"qid": "1", "vote": "A"})
|
||||||
|
|
||||||
|
self.login()
|
||||||
|
response = self.client.post(
|
||||||
|
"/admin/question/edit/1",
|
||||||
|
data={
|
||||||
|
"date": date.today().isoformat(),
|
||||||
|
"question": "Would you rather retest A or retest B?",
|
||||||
|
"option_a": "Retest A",
|
||||||
|
"option_b": "Retest B",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
count_after_edit = (
|
||||||
|
get_db().execute("SELECT COUNT(*) FROM votes").fetchone()[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(count_after_edit, 0)
|
||||||
|
|
||||||
|
self.client.post("/vote", data={"qid": "1", "vote": "B"})
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
db = get_db()
|
||||||
|
total = db.execute("SELECT COUNT(*) FROM votes").fetchone()[0]
|
||||||
|
vote_b = db.execute(
|
||||||
|
"SELECT COUNT(*) FROM votes WHERE vote = 'B'",
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
self.assertEqual(total, 1)
|
||||||
|
self.assertEqual(vote_b, 1)
|
||||||
|
|
||||||
def test_admin_login_success_and_failure(self):
|
def test_admin_login_success_and_failure(self):
|
||||||
failed = self.client.post(
|
failed = self.client.post(
|
||||||
"/admin/login",
|
"/admin/login",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user