aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README.md11
-rw-r--r--app.py688
-rw-r--r--templates/index.html.j241
-rw-r--r--templates/policy_sample.html.j2137
5 files changed, 565 insertions, 313 deletions
diff --git a/.gitignore b/.gitignore
index b499007..a486141 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ __pycache__
db.sqlite3*
db/
pyrightconfig.json
+templates/policy.html.j2
diff --git a/README.md b/README.md
index d6cd38f..ebe067c 100644
--- a/README.md
+++ b/README.md
@@ -36,9 +36,14 @@ You can also run the app without containerization:
| EMAIL_USE_TLS | Use STARTTLS with SMTP? |
| EMAIL_HEADERS | Additional SMTP headers, format: `header1=foo,header2=bar` |
| EMAIL_MESSAGE_FROM | Email message from address |
+| POLICY_URL | Address of the policy & terms page |
+| POLICY_INSTANCE_DOMAIN | The domain part of the BASE_URL |
+| POLICY_CONTACT_EMAIL | Your contact email |
`EMAIL_` variables are only required if at least one of them is defined.
+`POLICY_` variables are used for the Privacy policy & Terms of use page. See relevant section below.
+
## Screenshots
### Front page
@@ -62,3 +67,9 @@ You can also run the app without containerization:
Run Flask in dev mode:
flask --app app --debug run -p 8000
+
+## Privacy policy and Terms of use page
+
+By defining a privacy and terms address in `POLICY_URL`, a link to it is rendered in the front page footer. You may set the value to `/policy` and create a `templates/policy.html.j2` file to serve a policy page as part of the app. The repository contains a sample policy template in `templates/policy_sample.html.j2` that uses the environment variables `POLICY_INSTANCE_DOMAIN` and `POLICY_CONTACT_EMAIL`. You may copy that sample to `templates/policy.html.j2` and make necessary changes to reflect the reality of your hosted environment.
+
+The developers of `diddle` take no responsibility about the content and legality of any policy you display to your users, whether it's based on the sample policy or not.
diff --git a/app.py b/app.py
index 0bd832f..ef4d618 100644
--- a/app.py
+++ b/app.py
@@ -1,29 +1,36 @@
-from dotenv import load_dotenv
-load_dotenv()
-
import datetime
import os
+import queue
+import subprocess
import sys
-import traceback
-import uuid
import threading
-import queue
import time
-from typing import Callable
-from user_agents import parse as parse_user_agent
+import traceback
+import uuid
from dataclasses import dataclass
-from flask import Flask, render_template, redirect, request, make_response
+from typing import Callable
+
+from dotenv import load_dotenv
+from flask import Flask, make_response, redirect, render_template, request
from flask_compress import Compress
+from jinja2.exceptions import TemplateNotFound
+from user_agents import parse as parse_user_agent
+
+_ = load_dotenv()
import db
import email_client
BASE_URL = os.environ["BASE_URL"]
+POLICY_INSTANCE_DOMAIN = os.environ["POLICY_INSTANCE_DOMAIN"]
+POLICY_CONTACT_EMAIL = os.environ["POLICY_CONTACT_EMAIL"]
+
@dataclass
class ChoicesByVoter:
- name: str
- votes: list[bool]
+ name: str
+ votes: list[bool]
+
TITLE_MAX_LENGTH = 100
DESCRIPTION_MAX_LENGTH = 1000
@@ -36,392 +43,479 @@ background_tasks_queue: queue.Queue[Task] = queue.Queue()
### Init
+
def background_thread():
- print("Background thread started")
- while True:
- task = background_tasks_queue.get()
- task()
- time.sleep(0.1)
+ print("Background thread started")
+ while True:
+ task = background_tasks_queue.get()
+ task()
+ time.sleep(0.1)
+
+
+def get_version() -> str | None:
+ try:
+ return (
+ subprocess.check_output(
+ ["git", "describe", "--tags", "--always"],
+ stderr=subprocess.DEVNULL,
+ text=True,
+ ).strip()
+ or None
+ )
+ except Exception:
+ return None
+
+
+APP_VERSION = get_version()
+
def create_app() -> Flask:
threading.Thread(target=background_thread, daemon=True).start()
app = Flask(__name__)
Compress(app)
+ app.jinja_env.globals["app_version"] = APP_VERSION
return app
+
app = create_app()
+
+def policy_page_exists():
+ try:
+ _ = app.jinja_env.get_template("policy.html.j2")
+ return True
+ except TemplateNotFound:
+ return False
+
+
+POLICY_PAGE_EXISTS = policy_page_exists()
+
### Routes
+
def voter_selection_on_choice(voter_name: str, choice: db.Choice) -> int | None:
- for vote in choice.votes:
- if vote.voter_name == voter_name:
- return vote.value
- return None
+ for vote in choice.votes:
+ if vote.voter_name == voter_name:
+ return vote.value
+ return None
+
def error_page(message: str, code: int = 400):
- return render_template("error.html.j2", error=message), code
+ return render_template("error.html.j2", error=message), code
+
@app.errorhandler(404)
-def not_found(e):
- return error_page("Not found", 404)
+def not_found(_e: BaseException | None = None):
+ return error_page("Not found", 404)
+
@app.errorhandler(Exception)
-def error_handler(e):
- traceback.print_exception(e, file=sys.stderr)
- return error_page("Internal server error", 500)
+def error_handler(e: BaseException):
+ traceback.print_exception(e, file=sys.stderr)
+ return error_page("Internal server error", 500)
+
def validate_uuid(s: str) -> bool:
- try:
- uuid.UUID(s)
- return True
- except ValueError:
- return False
+ try:
+ _ = uuid.UUID(s)
+ return True
+ except ValueError:
+ return False
+
+
+@app.route("/policy")
+def privacy():
+ if not POLICY_PAGE_EXISTS:
+ return not_found()
+
+ return render_template(
+ "policy.html.j2",
+ instance_domain=POLICY_INSTANCE_DOMAIN,
+ contact_email=POLICY_CONTACT_EMAIL,
+ )
+
@app.route("/")
def index():
- created_poll_codes = []
- for k, _ in request.cookies.items():
- if k.startswith("diddle_manage_code_"):
- created_poll_codes.append(k.replace("diddle_manage_code_", ""))
+ created_poll_codes: list[str] = []
+ for k, _ in request.cookies.items():
+ if k.startswith("diddle_manage_code_"):
+ created_poll_codes.append(k.replace("diddle_manage_code_", ""))
+
+ created_polls = (
+ db.get_polls_by_codes(created_poll_codes) if len(created_poll_codes) > 0 else []
+ )
- created_polls = db.get_polls_by_codes(created_poll_codes) if len(created_poll_codes) > 0 else []
+ return render_template(
+ "index.html.j2",
+ created_polls=created_polls,
+ now=datetime.datetime.now(),
+ has_policy=POLICY_PAGE_EXISTS,
+ )
- return render_template('index.html.j2',
- created_polls=created_polls,
- now=datetime.datetime.now())
@app.post("/poll/create")
def create():
- form = request.form
+ form = request.form
- title = form.get("title")
- title = title.strip() if title is not None else None
- if title is None or len(title) == 0:
- return error_page("Title is required")
- if len(title) > TITLE_MAX_LENGTH:
- return error_page(f"Title must be {TITLE_MAX_LENGTH} characters or fewer")
- description = form.get("description")
- description = description.strip() if description is not None else None
- if description is not None and len(description) > DESCRIPTION_MAX_LENGTH:
- return error_page(f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer")
- author_name = form.get("author_name")
- author_name = author_name.strip() if author_name is not None else None
- if author_name is None or len(author_name) == 0:
- return error_page("Author name is required")
- if len(author_name) > AUTHOR_NAME_MAX_LENGTH:
- return error_page(f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer")
- author_email = form.get("author_email")
- author_email = author_email.strip() if author_email is not None else None
- if author_email is not None and len(author_email) > AUTHOR_EMAIL_MAX_LENGTH:
- return error_page(f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer")
+ title = form.get("title")
+ title = title.strip() if title is not None else None
+ if title is None or len(title) == 0:
+ return error_page("Title is required")
+ if len(title) > TITLE_MAX_LENGTH:
+ return error_page(f"Title must be {TITLE_MAX_LENGTH} characters or fewer")
+ description = form.get("description")
+ description = description.strip() if description is not None else None
+ if description is not None and len(description) > DESCRIPTION_MAX_LENGTH:
+ return error_page(
+ f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer"
+ )
+ author_name = form.get("author_name")
+ author_name = author_name.strip() if author_name is not None else None
+ if author_name is None or len(author_name) == 0:
+ return error_page("Author name is required")
+ if len(author_name) > AUTHOR_NAME_MAX_LENGTH:
+ return error_page(
+ f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer"
+ )
+ author_email = form.get("author_email")
+ author_email = author_email.strip() if author_email is not None else None
+ if author_email is not None and len(author_email) > AUTHOR_EMAIL_MAX_LENGTH:
+ return error_page(
+ f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer"
+ )
- poll = db.create_poll(
- title,
- description,
- author_name,
- author_email,
- "is_whole_day" in form,
- )
+ poll = db.create_poll(
+ title,
+ description,
+ author_name,
+ author_email,
+ "is_whole_day" in form,
+ )
- if email_client.email_enabled:
- def task():
- email_client.send_poll_created_email(poll_id=poll.id)
+ if email_client.email_enabled:
- background_tasks_queue.put(task)
+ def task():
+ email_client.send_poll_created_email(poll_id=poll.id)
- resp = make_response(
- redirect(f"/manage/{poll.manage_code}")
- )
- resp.set_cookie(f"diddle_manage_code_{poll.manage_code}", "1",
- samesite="Strict", secure=False)
- return resp
+ background_tasks_queue.put(task)
+
+ resp = make_response(redirect(f"/manage/{poll.manage_code}"))
+ resp.set_cookie(
+ f"diddle_manage_code_{poll.manage_code}", "1", samesite="Strict", secure=False
+ )
+ return resp
VoterNameChoiceIdPair = tuple[str, str]
+
+
@app.get("/poll/<id>")
-def poll(id):
- if not validate_uuid(id):
- return error_page("Invalid poll ID", 400)
+def poll(id: str):
+ if not validate_uuid(id):
+ return error_page("Invalid poll ID", 400)
- prefill_voter_name = request.args.get("prefill_voter_name")
+ prefill_voter_name = request.args.get("prefill_voter_name")
- voter_codes: list[str] = []
- for k, _ in request.cookies.items():
- if k.startswith("diddle_voter_code_"):
- voter_codes.append(k.replace("diddle_voter_code_", ""))
+ voter_codes: list[str] = []
+ for k, _ in request.cookies.items():
+ if k.startswith("diddle_voter_code_"):
+ voter_codes.append(k.replace("diddle_voter_code_", ""))
- poll = db.get_poll(id)
- if poll is None:
- return error_page("Poll not found", 404)
+ poll = db.get_poll(id)
+ if poll is None:
+ return error_page("Poll not found", 404)
- display_mode_cookie = request.cookies.get("diddle_display_mode")
- if display_mode_cookie is None:
- user_agent = parse_user_agent(request.user_agent.string)
- if user_agent.is_mobile or user_agent.is_tablet:
- display_mode = "list"
+ display_mode_cookie = request.cookies.get("diddle_display_mode")
+ if display_mode_cookie is None:
+ user_agent = parse_user_agent(request.user_agent.string)
+ if user_agent.is_mobile or user_agent.is_tablet:
+ display_mode = "list"
+ else:
+ display_mode = "table"
else:
- display_mode = "table"
- else:
- display_mode = display_mode_cookie
+ display_mode = display_mode_cookie
- voter_names_set: set[str] = set()
- selections: dict[VoterNameChoiceIdPair, int] = {}
- managed_voter_names: dict[str, str] = {}
- for choice in poll.choices:
- for vote in choice.votes:
- selections[(vote.voter_name, choice.id)] = vote.value
- voter_names_set.add(vote.voter_name)
+ voter_names_set: set[str] = set()
+ selections: dict[VoterNameChoiceIdPair, int] = {}
+ managed_voter_names: dict[str, str] = {}
+ for choice in poll.choices:
+ for vote in choice.votes:
+ selections[(vote.voter_name, choice.id)] = vote.value
+ voter_names_set.add(vote.voter_name)
- if vote.manage_code in voter_codes:
- managed_voter_names[vote.voter_name] = vote.manage_code
+ if vote.manage_code in voter_codes:
+ managed_voter_names[vote.voter_name] = vote.manage_code
- voter_names = list(voter_names_set)
- voter_names.sort()
+ voter_names = list(voter_names_set)
+ voter_names.sort()
- most_voted_choice_ids: set[str] = set()
- most_voted_value = 1 # start from 1 to avoid marking 0 votes as most voted
- for choice in poll.choices:
- n_votes = 0
- for vote in choice.votes:
- n_votes += vote.value
+ most_voted_choice_ids: set[str] = set()
+ most_voted_value = 1 # start from 1 to avoid marking 0 votes as most voted
+ for choice in poll.choices:
+ n_votes = 0
+ for vote in choice.votes:
+ n_votes += vote.value
- if n_votes > most_voted_value:
- most_voted_choice_ids = { choice.id }
- most_voted_value = n_votes
- elif n_votes == most_voted_value:
- most_voted_choice_ids.add(choice.id)
+ if n_votes > most_voted_value:
+ most_voted_choice_ids = {choice.id}
+ most_voted_value = n_votes
+ elif n_votes == most_voted_value:
+ most_voted_choice_ids.add(choice.id)
- resp = make_response(
- render_template("poll.html.j2",
- poll=poll,
- selections=selections,
- choices=poll.choices,
- most_voted_choice_ids=most_voted_choice_ids,
- prefill_voter_name=prefill_voter_name,
- voter_names=voter_names,
- managed_voter_names=managed_voter_names,
- now=datetime.datetime.now(),
- display_mode=display_mode))
+ resp = make_response(
+ render_template(
+ "poll.html.j2",
+ poll=poll,
+ selections=selections,
+ choices=poll.choices,
+ most_voted_choice_ids=most_voted_choice_ids,
+ prefill_voter_name=prefill_voter_name,
+ voter_names=voter_names,
+ managed_voter_names=managed_voter_names,
+ now=datetime.datetime.now(),
+ display_mode=display_mode,
+ )
+ )
- resp.set_cookie("diddle_display_mode", display_mode,
- samesite="Lax", secure=False)
- return resp
+ resp.set_cookie("diddle_display_mode", display_mode, samesite="Lax", secure=False)
+ return resp
@app.post("/poll/<id>/vote")
-def vote_poll(id):
- if not validate_uuid(id):
- return error_page("Invalid poll ID", 400)
+def vote_poll(id: str):
+ if not validate_uuid(id):
+ return error_page("Invalid poll ID", 400)
- form = request.form
- voter_name = form.get("voter_name")
- voter_name = voter_name.strip() if voter_name is not None else None
- if voter_name is None or len(voter_name) == 0:
- return error_page("Voter name is required")
- if len(voter_name) > VOTER_NAME_MAX_LENGTH:
- return error_page(f"Voter name must be {VOTER_NAME_MAX_LENGTH} characters or fewer")
+ form = request.form
+ voter_name = form.get("voter_name")
+ voter_name = voter_name.strip() if voter_name is not None else None
+ if voter_name is None or len(voter_name) == 0:
+ return error_page("Voter name is required")
+ if len(voter_name) > VOTER_NAME_MAX_LENGTH:
+ return error_page(
+ f"Voter name must be {VOTER_NAME_MAX_LENGTH} characters or fewer"
+ )
- poll = db.get_poll(id)
- if poll is None:
- return error_page("Poll not found")
+ poll = db.get_poll(id)
+ if poll is None:
+ return error_page("Poll not found")
- selections: dict[str, int] = {}
- for choice in poll.choices:
- selections[choice.id] = 0
+ selections: dict[str, int] = {}
+ for choice in poll.choices:
+ selections[choice.id] = 0
- for k in form.keys():
- if k.startswith("choice_"):
- choice_id = k.replace("choice_", "")
- selections[choice_id] = 1
+ for k in form.keys():
+ if k.startswith("choice_"):
+ choice_id = k.replace("choice_", "")
+ selections[choice_id] = 1
- manage_code = db.vote_poll(id, voter_name, selections)
- if manage_code is None:
- return error_page("That name is already in use")
+ manage_code = db.vote_poll(id, voter_name, selections)
+ if manage_code is None:
+ return error_page("That name is already in use")
- if email_client.email_enabled:
- def task():
- email_client.send_participation_email(poll_id=id, voter_name=voter_name)
- background_tasks_queue.put(task)
+ if email_client.email_enabled:
+
+ def task():
+ email_client.send_participation_email(poll_id=id, voter_name=voter_name)
+
+ background_tasks_queue.put(task)
+
+ response = make_response(redirect(f"/poll/{id}"))
+ response.set_cookie(
+ f"diddle_voter_code_{manage_code}", "1", samesite="Strict", secure=False
+ )
+ return response
- response = make_response(
- redirect(f"/poll/{id}")
- )
- response.set_cookie(f"diddle_voter_code_{manage_code}", "1",
- samesite="Strict", secure=False)
- return response
@app.post("/poll/<id>/delete_voter")
-def delete_voter(id):
- if not validate_uuid(id):
- return error_page("Invalid poll ID", 400)
+def delete_voter(id: str):
+ if not validate_uuid(id):
+ return error_page("Invalid poll ID", 400)
+
+ voter_code = request.form["voter_code"]
+ voter_name = db.get_voter_name_by_manage_code(voter_code)
- voter_code = request.form["voter_code"]
- voter_name = db.get_voter_name_by_manage_code(voter_code)
+ return render_template(
+ "voter_confirm_delete.html.j2",
+ voter_name=voter_name,
+ voter_code=voter_code,
+ poll_id=id,
+ )
- return render_template("voter_confirm_delete.html.j2",
- voter_name=voter_name,
- voter_code=voter_code,
- poll_id=id)
@app.post("/poll/<id>/confirm_delete_voter")
-def confirm_delete_voter(id):
- if not validate_uuid(id):
- return error_page("Invalid poll ID", 400)
+def confirm_delete_voter(id: str):
+ if not validate_uuid(id):
+ return error_page("Invalid poll ID", 400)
- voter_manage_code = request.form["voter_code"]
+ voter_manage_code = request.form["voter_code"]
- voter_name = db.get_voter_name_by_manage_code(voter_manage_code)
- if voter_name is None:
- return error_page("Voter not found", 404)
+ voter_name = db.get_voter_name_by_manage_code(voter_manage_code)
+ if voter_name is None:
+ return error_page("Voter not found", 404)
- db.delete_voter(voter_manage_code)
+ db.delete_voter(voter_manage_code)
+
+ resp = make_response(redirect(f"/poll/{id}?prefill_voter_name={voter_name}"))
+ resp.set_cookie(
+ f"diddle_voter_code_{voter_manage_code}",
+ "",
+ expires=0,
+ samesite="Strict",
+ secure=False,
+ )
+ return resp
- resp = make_response(
- redirect(f"/poll/{id}?prefill_voter_name={voter_name}")
- )
- resp.set_cookie(f"diddle_voter_code_{voter_manage_code}", "", expires=0,
- samesite="Strict", secure=False)
- return resp
@app.post("/manage/<code>/update_info")
-def update_poll_info(code):
- if not validate_uuid(code):
- return error_page("Invalid manage code", 400)
+def update_poll_info(code: str):
+ if not validate_uuid(code):
+ return error_page("Invalid manage code", 400)
+
+ form = request.form
- form = request.form
+ title = form.get("title")
+ title = title.strip() if title is not None else None
+ if title is None or len(title) == 0:
+ return error_page("Title is required")
+ if len(title) > TITLE_MAX_LENGTH:
+ return error_page(f"Title must be {TITLE_MAX_LENGTH} characters or fewer")
+ description = form.get("description")
+ description = description.strip() if description is not None else None
+ if description is not None and len(description) > DESCRIPTION_MAX_LENGTH:
+ return error_page(
+ f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer"
+ )
+ author_name = form.get("author_name")
+ author_name = author_name.strip() if author_name is not None else None
+ if author_name is None or len(author_name) == 0:
+ return error_page("Author name is required")
+ if len(author_name) > AUTHOR_NAME_MAX_LENGTH:
+ return error_page(
+ f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer"
+ )
+ author_email = form.get("author_email")
+ author_email = author_email.strip() if author_email is not None else None
+ if author_email is not None and len(author_email) > AUTHOR_EMAIL_MAX_LENGTH:
+ return error_page(
+ f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer"
+ )
- title = form.get("title")
- title = title.strip() if title is not None else None
- if title is None or len(title) == 0:
- return error_page("Title is required")
- if len(title) > TITLE_MAX_LENGTH:
- return error_page(f"Title must be {TITLE_MAX_LENGTH} characters or fewer")
- description = form.get("description")
- description = description.strip() if description is not None else None
- if description is not None and len(description) > DESCRIPTION_MAX_LENGTH:
- return error_page(f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer")
- author_name = form.get("author_name")
- author_name = author_name.strip() if author_name is not None else None
- if author_name is None or len(author_name) == 0:
- return error_page("Author name is required")
- if len(author_name) > AUTHOR_NAME_MAX_LENGTH:
- return error_page(f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer")
- author_email = form.get("author_email")
- author_email = author_email.strip() if author_email is not None else None
- if author_email is not None and len(author_email) > AUTHOR_EMAIL_MAX_LENGTH:
- return error_page(f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer")
+ changed = db.update_poll_info(
+ code,
+ title,
+ description,
+ author_name,
+ author_email,
+ "is_whole_day" in form,
+ )
- changed = db.update_poll_info(
- code,
- title,
- description,
- author_name,
- author_email,
- "is_whole_day" in form,
- )
+ if changed is None:
+ return error_page("Poll not found", 404)
- if changed is None:
- return error_page("Poll not found", 404)
+ return redirect(f"/manage/{code}")
- return redirect(f"/manage/{code}")
@app.post("/manage/<code>/add_choice")
-def add_choice(code):
- if not validate_uuid(code):
- return error_page("Invalid manage code", 400)
+def add_choice(code: str):
+ if not validate_uuid(code):
+ return error_page("Invalid manage code", 400)
+
+ form = request.form
+ if "start_datetime" not in form or len(form["start_datetime"]) == 0:
+ return error_page("Start datetime is required")
+ if "end_datetime" not in form or len(form["end_datetime"]) == 0:
+ return error_page("End datetime is required")
+ if form["start_datetime"] > form["end_datetime"]:
+ return error_page("Start datetime must be before end datetime")
- form = request.form
- if "start_datetime" not in form or len(form["start_datetime"]) == 0:
- return error_page("Start datetime is required")
- if "end_datetime" not in form or len(form["end_datetime"]) == 0:
- return error_page("End datetime is required")
- if form["start_datetime"] > form["end_datetime"]:
- return error_page("Start datetime must be before end datetime")
+ start_datetime = form["start_datetime"]
+ if len(start_datetime) == 10:
+ start_datetime += " 00:00:00"
+ else:
+ start_datetime = start_datetime.replace("T", " ")
+ start_datetime += ":00"
- start_datetime = form["start_datetime"]
- if len(start_datetime) == 10:
- start_datetime += " 00:00:00"
- else:
- start_datetime = start_datetime.replace("T", " ")
- start_datetime += ":00"
+ end_datetime = form["end_datetime"]
+ if len(end_datetime) == 10:
+ end_datetime += " 23:59:00"
+ else:
+ end_datetime = end_datetime.replace("T", " ")
+ end_datetime += ":00"
- end_datetime = form["end_datetime"]
- if len(end_datetime) == 10:
- end_datetime += " 23:59:00"
- else:
- end_datetime = end_datetime.replace("T", " ")
- end_datetime += ":00"
+ db.add_choice_to_poll(
+ code,
+ start_datetime,
+ end_datetime,
+ )
- db.add_choice_to_poll(
- code,
- start_datetime,
- end_datetime,
- )
+ return redirect(f"/manage/{code}?focus_next=1")
- return redirect(f"/manage/{code}?focus_next=1")
@app.post("/manage/<code>/delete_choice/<choice_id>")
-def delete_choice(code, choice_id):
- if not validate_uuid(code):
- return error_page("Invalid manage code", 400)
+def delete_choice(code: str, choice_id: str):
+ if not validate_uuid(code):
+ return error_page("Invalid manage code", 400)
- poll = db.get_poll_by_code(code)
- if poll is None:
- return error_page("Poll not found", code=404)
+ poll = db.get_poll_by_code(code)
+ if poll is None:
+ return error_page("Poll not found", code=404)
- db.delete_choice(choice_id)
+ db.delete_choice(choice_id)
+
+ return redirect(f"/manage/{code}?focus_next=1")
- return redirect(f"/manage/{code}?focus_next=1")
@app.get("/manage/<code>")
-def manage(code):
- if not validate_uuid(code):
- return error_page("Invalid manage code", 400)
+def manage(code: str):
+ if not validate_uuid(code):
+ return error_page("Invalid manage code", 400)
+
+ poll = db.get_poll_by_code(code)
+ if poll is None:
+ return error_page("Poll not found")
- poll = db.get_poll_by_code(code)
- if poll is None:
- return error_page("Poll not found")
+ last_choice_id = poll.choices[-1].id if len(poll.choices) > 0 else None
+ resp = make_response(
+ render_template("manage.html.j2", poll=poll, last_choice_id=last_choice_id)
+ )
+ resp.set_cookie(f"diddle_manage_code_{code}", "1", samesite="Strict", secure=False)
+ return resp
- last_choice_id = poll.choices[-1].id if len(poll.choices) > 0 else None
- resp = make_response(
- render_template("manage.html.j2",
- poll=poll,
- last_choice_id=last_choice_id))
- resp.set_cookie(f"diddle_manage_code_{code}", "1",
- samesite="Strict", secure=False)
- return resp
@app.post("/manage/<code>/delete")
-def delete_poll(code):
- if not validate_uuid(code):
- return error_page("Invalid manage code", 400)
+def delete_poll(code: str):
+ if not validate_uuid(code):
+ return error_page("Invalid manage code", 400)
+
+ poll = db.get_poll_by_code(code)
+ return render_template("poll_confirm_delete.html.j2", poll=poll)
- poll = db.get_poll_by_code(code)
- return render_template("poll_confirm_delete.html.j2", poll=poll)
@app.post("/manage/<code>/confirm_delete")
-def confirm_delete_poll(code):
- if not validate_uuid(code):
- return error_page("Invalid manage code", 400)
+def confirm_delete_poll(code: str):
+ if not validate_uuid(code):
+ return error_page("Invalid manage code", 400)
+
+ db.delete_poll(code)
+ resp = make_response(redirect("/"))
+ resp.set_cookie(
+ f"diddle_manage_code_{code}", "", expires=0, samesite="Strict", secure=False
+ )
+ return resp
- db.delete_poll(code)
- resp = make_response(redirect("/"))
- resp.set_cookie(f"diddle_manage_code_{code}", "", expires=0,
- samesite="Strict", secure=False)
- return resp
@app.post("/options/toggle_display_mode")
def toggle_display_mode():
- poll_id = request.form["poll_id"]
- redirect_url = f"/poll/{poll_id}"
- display_mode = request.cookies.get("diddle_display_mode", "table")
- if display_mode == "table":
- display_mode = "list"
- else:
- display_mode = "table"
+ poll_id = request.form["poll_id"]
+ redirect_url = f"/poll/{poll_id}"
+ display_mode = request.cookies.get("diddle_display_mode", "table")
+ if display_mode == "table":
+ display_mode = "list"
+ else:
+ display_mode = "table"
- resp = make_response(redirect(redirect_url))
- resp.set_cookie("diddle_display_mode", display_mode,
- samesite="Lax", secure=False)
- return resp
+ resp = make_response(redirect(redirect_url))
+ resp.set_cookie("diddle_display_mode", display_mode, samesite="Lax", secure=False)
+ return resp
diff --git a/templates/index.html.j2 b/templates/index.html.j2
index ea2e225..9daee75 100644
--- a/templates/index.html.j2
+++ b/templates/index.html.j2
@@ -1,32 +1,41 @@
-{% extends "base.html.j2" %}
-
-{% block content %}
+{% extends "base.html.j2" %} {% block content %}
<h2>Create new diddle</h2>
<form class="poll-form" action="/poll/create" method="post">
- {% include "poll_info_table.html.j2" %}
+ {% include "poll_info_table.html.j2" %}
- <p>You can add time options as well as modify all settings after submitting.</p>
- <input class="blue" type="submit" value="Create">
+ <p>
+ You can add time options as well as modify all settings after
+ submitting.
+ </p>
+ <input class="blue" type="submit" value="Create" />
</form>
{% if created_polls | length != 0 %}
-<br>
+<br />
<h2>My diddles</h2>
<ul>
- {% for poll in created_polls %}
- <li><a href="/manage/{{ poll.manage_code }}">{{ poll.title }}</a></li>
- {% endfor %}
+ {% for poll in created_polls %}
+ <li><a href="/manage/{{ poll.manage_code }}">{{ poll.title }}</a></li>
+ {% endfor %}
</ul>
{% endif %}
-<br>
-<br>
+<br />
+<br />
<h2>About diddle</h2>
-<p><i>diddle</i> is a minimalist, mobile friendly, fast and self-hosted scheduling tool,
- licensed under <a href="https://github.com/jantuomi/diddle/blob/main/LICENSE">Apache 2.0.</a>
+<p>
+ <i>diddle</i> is a minimalist, mobile friendly, fast and self-hosted scheduling tool, licensed under
+ <a href="https://github.com/jantuomi/diddle/blob/main/LICENSE">Apache 2.0.</a><br />
+ Find <i>diddle</i> on <a href="https://github.com/jantuomi/diddle">GitHub</a>.<br />
+ This site uses the <a href="https://fonts.google.com/specimen/Inclusive+Sans">Inclusive Sans</a> typeface.
</p>
<p>
- Find <i>diddle</i> on <a href="https://github.com/jantuomi/diddle">GitHub</a>. © {{ now.year }} Jan Tuomi. All rights reserved.<br>
- This site uses the <a href="https://fonts.google.com/specimen/Inclusive+Sans">Inclusive Sans</a> typeface.
+ © {{ now.year }} Jan Tuomi. All rights reserved.
+ {% if has_policy %}
+ <a href="/policy">Privacy and terms</a>.
+ {% endif %}
+ {% if app_version %}
+ <br />Version: <span style="font-family: monospace">{{ app_version }}</span>.
+ {% endif %}
</p>
{% endblock %}
diff --git a/templates/policy_sample.html.j2 b/templates/policy_sample.html.j2
new file mode 100644
index 0000000..92b88a5
--- /dev/null
+++ b/templates/policy_sample.html.j2
@@ -0,0 +1,137 @@
+{% extends "base.html.j2" %}
+
+{% block head_meta %}
+<title>Privacy and terms – {{ instance_domain }} 👉👈</title>
+<meta
+ name="description"
+ content="Privacy policy and terms of use for {{ instance_domain }}"
+/>
+{% endblock %}
+
+{% block content %}
+
+<h2 style="color: red">This is a sample privacy policy and terms of use document. You must replace this with your own when deploying.</h2>
+
+<p>Last updated: 19 April 2026</p>
+
+<hr />
+
+<h2>Terms of use</h2>
+
+<h3>Acceptance of terms</h3>
+<p>
+ By using <i>diddle</i> on <a href="/">{{ instance_domain }}</a>, you agree to these terms. If you do not agree, please do not use the service.
+</p>
+
+<h3>Description of service</h3>
+<p>
+ <i>diddle</i> is a self-hosted scheduling tool that allows users to create polls and vote on time options. The service is provided as-is and free-of-charge.
+</p>
+
+<h3>User responsibilities</h3>
+<p>You agree not to:</p>
+<ul>
+ <li>Use the service for any unlawful purpose.</li>
+ <li>Submit content that is offensive, harmful, or infringes on the rights of others.</li>
+ <li>Attempt to disrupt or compromise the service or its infrastructure.</li>
+</ul>
+
+<h3>Disclaimer of warranties</h3>
+<p>
+ The service is provided "as is" and "as available" without warranties of any
+ kind, whether express or implied. We do not guarantee that the service will
+ be uninterrupted, error-free, or that any data will be retained or preserved.
+</p>
+
+<h3>Limitation of liability</h3>
+<p>
+ To the fullest extent permitted by law, the operator of this diddle instance
+ shall not be liable for any indirect, incidental, or consequential damages
+ arising from your use of the service, including but not limited to loss of data.
+</p>
+
+<h3>Changes to these terms</h3>
+<p>
+ We may update these terms at any time. Continued use of the service after
+ changes constitutes acceptance of the updated terms.
+</p>
+
+<hr />
+
+<h2>Privacy policy</h2>
+
+<h3>What data do we collect?</h3>
+<p>When you use <a href="/">{{ instance_domain }}</a>, we collect the data you provide directly:</p>
+<ul>
+ <li>Poll: title, description, author name, author email address, poll settings.</li>
+ <li>Votes: voter name and selected time choices.</li>
+</ul>
+<p>
+ Additionally, connection information such as your IP address may be stored in server logs for administrative purposes.
+</p>
+<p>
+ We do not collect any other kinds of data.
+</p>
+
+<h3>How do we use your data?</h3>
+<p>
+ Your data is used solely to provide the scheduling functionality of diddle.
+ Your email address maybe used to send you notifications about polls you manage.
+ We do not use your data for any other purpose.
+</p>
+
+<h3>Cookies</h3>
+<p>
+ Diddle uses only strictly necessary functional cookies to remember which
+ polls you have created or voted in, and your display mode preference. These
+ cookies do not track you across websites and are not used for analytics,
+ advertising, or any other purpose.
+</p>
+
+<h3>Data storage and transfers</h3>
+<p>
+ All data is stored within the European Union.
+ Your data is not transferred outside the EU and is not shared with any third parties.
+ Your data is stored encrypted at rest and in flight.
+</p>
+
+<h3>Data retention</h3>
+<p>
+ No specific data retention period is guaranteed.
+ No data durability or backup guarantees are provided.
+ Data may be lost due to technical failures.
+</p>
+
+<h3>Data deletion</h3>
+<p>
+ By using the <i>Delete poll</i> functionality, poll data and any related data is deleted from the database.
+</p>
+<p>
+ By using the <i>Delete voter</i> functionality, voter name and selected time choices are deleted from the database.
+</p>
+
+<h3>Your rights</h3>
+<p>
+ Under the General Data Protection Regulation (GDPR), you have the right to:
+</p>
+<ul>
+ <li>Access the personal data we hold about you.</li>
+ <li>Request correction of inaccurate data.</li>
+ <li>Request erasure of your data.</li>
+ <li>Restrict or object to processing of your data.</li>
+ <li>Request portability of your data.</li>
+</ul>
+<p>
+ To exercise any of these rights, please contact us using the details below.
+ Data is removed from the database upon request. If we are unable to verify
+ your identity, we may not be able to act on your request.
+</p>
+
+<hr />
+
+<h3>Contact</h3>
+<p>
+ Email: {{ contact_email }}
+</p>
+<br />
+{% endblock %}