diff options
| author | Jan T <jan@jantuomi.fi> | 2024-02-28 17:32:01 +0200 |
|---|---|---|
| committer | Jan Tuomi <jan@jantuomi.fi> | 2024-12-21 19:43:13 +0200 |
| commit | b6bc5c5794b4df182175da34e6b08298084c0324 (patch) | |
| tree | 501362805ac744eb71e0e9d7b70addb9f0c9ae99 | |
Initial commit
| -rw-r--r-- | .gitignore | 4 | ||||
| -rw-r--r-- | Dockerfile | 13 | ||||
| -rw-r--r-- | LICENSE | 201 | ||||
| -rw-r--r-- | app.py | 221 | ||||
| -rw-r--r-- | apply_migrations.py | 25 | ||||
| -rw-r--r-- | compose.yml | 31 | ||||
| -rw-r--r-- | db.py | 286 | ||||
| -rw-r--r-- | migrations/0000_initial.sql | 28 | ||||
| -rw-r--r-- | migrations/0001_indices.sql | 3 | ||||
| -rw-r--r-- | requirements.txt | 11 | ||||
| -rwxr-xr-x | scripts/container_entrypoint.sh | 5 | ||||
| -rw-r--r-- | static/styles.css | 133 | ||||
| -rw-r--r-- | templates/base.html.j2 | 13 | ||||
| -rw-r--r-- | templates/error.html.j2 | 6 | ||||
| -rw-r--r-- | templates/index.html.j2 | 40 | ||||
| -rw-r--r-- | templates/manage.html.j2 | 119 | ||||
| -rw-r--r-- | templates/poll.html.j2 | 34 | ||||
| -rw-r--r-- | templates/poll_confirm_delete.html.j2 | 13 | ||||
| -rw-r--r-- | templates/poll_vote_list.html.j2 | 34 | ||||
| -rw-r--r-- | templates/poll_vote_table.html.j2 | 51 |
20 files changed, 1271 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ed2662 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv +__pycache__ +.vscode/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aabee15 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:alpine + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt +COPY migrations ./migrations +COPY scripts ./scripts +COPY static ./static +COPY templates ./templates +COPY *.py ./ + +EXPOSE 8000 +CMD ["sh", "scripts/container_entrypoint.sh"] @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. @@ -0,0 +1,221 @@ +from dotenv import load_dotenv +load_dotenv() + +import datetime +from dataclasses import dataclass +from flask import Flask, render_template, redirect, request, make_response +app = Flask(__name__) + +import db + +@dataclass +class ChoicesByVoter: + name: str + votes: list[bool] + +TITLE_MAX_LENGTH = 100 +DESCRIPTION_MAX_LENGTH = 1000 +AUTHOR_NAME_MAX_LENGTH = 100 +AUTHOR_EMAIL_MAX_LENGTH = 100 +VOTER_NAME_MAX_LENGTH = 100 + +def voter_voted_on_choice(voter_name: str, choice: db.Choice) -> bool: + for vote in choice.votes: + if vote.voter_name == voter_name: + return True + return False + +def validation_error(message: str): + return render_template("error.html.j2", error=message), 400 + +@app.route("/") +def hello_world(): + 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_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) + +@app.post("/poll/create") +def create(): + form = request.form + + if "title" not in form or len(form["title"]) == 0: + return validation_error("Title is required") + if len(form["title"]) > TITLE_MAX_LENGTH: + return validation_error(f"Title must be {TITLE_MAX_LENGTH} characters or fewer") + if "description" in form and len(form["description"]) > DESCRIPTION_MAX_LENGTH: + return validation_error(f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer") + if "author_name" not in form or len(form["author_name"]) == 0: + return validation_error("Author name is required") + if len(form["author_name"]) > AUTHOR_NAME_MAX_LENGTH: + return validation_error(f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer") + if "author_email" in form and len(form["author_email"]) > AUTHOR_EMAIL_MAX_LENGTH: + return validation_error(f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer") + + choices = [] + poll = db.create_poll( + form["title"], + form["description"], + form["author_name"], + form["author_email"], + choices, + ) + resp = make_response( + redirect(f"/manage/{poll.manage_code}") + ) + resp.set_cookie(f"diddle_manage_code_{poll.manage_code}", "1") + return resp + +@app.get("/poll/<id>") +def poll(id): + poll = db.get_poll(id) + if poll is None: + return "Poll not found", 404 + + display_mode = request.cookies.get("diddle_display_mode", "table") + + voter_names = [] + for choice in poll.choices: + for vote in choice.votes: + if vote.voter_name not in voter_names: + voter_names.append(vote.voter_name) + + choices_by_voter = [] + for voter_name in voter_names: + votes: list[bool] = [] + for choice in poll.choices: + voted = voter_voted_on_choice(voter_name, choice) + votes.append(voted) + + choices_by_voter.append( + ChoicesByVoter(name=voter_name, votes=votes) + ) + + choices_by_voter.sort(key=lambda x: x.name) + + return render_template('poll.html.j2', + poll=poll, + choices_by_voter=choices_by_voter, + choices=poll.choices, + now=datetime.datetime.now(), + display_mode=display_mode, + ) + +@app.post("/poll/<id>/vote") +def vote_poll(id): + form = request.form + if "voter_name" not in form or len(form["voter_name"]) == 0: + return validation_error("Voter name is required") + if len(form["voter_name"]) > VOTER_NAME_MAX_LENGTH: + return validation_error(f"Voter name must be {VOTER_NAME_MAX_LENGTH} characters or fewer") + + voter_name = form["voter_name"] + choice_ids = [] + for k in form.keys(): + if k.startswith("choice_"): + choice_id = k.replace("choice_", "") + choice_ids.append(choice_id) + + db.vote_poll(id, voter_name, choice_ids) + + return redirect(f"/poll/{id}") + +@app.post("/manage/<code>/update_info") +def update_poll_info(code): + form = request.form + + if "title" not in form or len(form["title"]) == 0: + return validation_error("Title is required") + if len(form["title"]) > TITLE_MAX_LENGTH: + return validation_error(f"Title must be {TITLE_MAX_LENGTH} characters or fewer") + if "description" in form and len(form["description"]) > DESCRIPTION_MAX_LENGTH: + return validation_error(f"Description must be {DESCRIPTION_MAX_LENGTH} characters or fewer") + if "author_name" not in form or len(form["author_name"]) == 0: + return validation_error("Author name is required") + if len(form["author_name"]) > AUTHOR_NAME_MAX_LENGTH: + return validation_error(f"Author name must be {AUTHOR_NAME_MAX_LENGTH} characters or fewer") + if "author_email" in form and len(form["author_email"]) > AUTHOR_EMAIL_MAX_LENGTH: + return validation_error(f"Author email must be {AUTHOR_EMAIL_MAX_LENGTH} characters or fewer") + + db.update_poll_info( + code, + form["title"], + form["description"], + form["author_name"], + form["author_email"], + ) + + return redirect(f"/manage/{code}") + +@app.post("/manage/<code>/add_choice") +def add_choice(code): + form = request.form + if "start_datetime" not in form or len(form["start_datetime"]) == 0: + return validation_error("Start datetime is required") + if "end_datetime" not in form or len(form["end_datetime"]) == 0: + return validation_error("End datetime is required") + if form["start_datetime"] >= form["end_datetime"]: + return validation_error("Start datetime must be before end datetime") + + db.add_choice_to_poll( + code, + form["start_datetime"], + form["end_datetime"], + ) + + return redirect(f"/manage/{code}") + +@app.post("/manage/<code>/delete_choice/<choice_id>") +def delete_choice(code, choice_id): + poll = db.get_poll_by_code(code) + if poll is None: + return "Poll not found", 404 + + db.delete_choice(choice_id) + + return redirect(f"/manage/{code}") + +@app.get("/manage/<code>") +def manage(code): + poll = db.get_poll_by_code(code) + if poll is None: + return validation_error("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") + return resp + +@app.post("/manage/<code>/delete") +def delete_poll(code): + 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): + db.delete_poll(code) + resp = make_response(redirect("/")) + resp.set_cookie(f"diddle_manage_code_{code}", "", expires=0) + 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" + + resp = make_response(redirect(redirect_url)) + resp.set_cookie("diddle_display_mode", display_mode) + return resp diff --git a/apply_migrations.py b/apply_migrations.py new file mode 100644 index 0000000..4ade81e --- /dev/null +++ b/apply_migrations.py @@ -0,0 +1,25 @@ +from dotenv import load_dotenv +load_dotenv() + +import os +import db + +migrations_dir = 'migrations' # Relative directory path + +# Get all migration files +migration_files = sorted(os.listdir(migrations_dir)) + +# Apply migrations in alphabetical order +for migration_file in migration_files: + migration_path = os.path.join(migrations_dir, migration_file) + print(f"* Applying migration: {migration_path}") + + with open(migration_path, 'r') as f: + migration_sql = f.read() + + print(migration_sql) + + db.apply_migration(migration_sql) + print(f"* Migration applied: {migration_path}") + +print(f"* {len(migration_files)} migrations applied.") diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..7fae4b0 --- /dev/null +++ b/compose.yml @@ -0,0 +1,31 @@ +services: + db: + image: postgres:14 + environment: + POSTGRES_DB: db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "54321:5432" + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U postgres'] + interval: 5s + timeout: 5s + retries: 5 + + web: + build: . + environment: + BASE_URL: http://localhost:8000 + DB_PASSWORD: postgres + DB_HOST: db + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + +volumes: + db-data: @@ -0,0 +1,286 @@ +import os +from dataclasses import dataclass +import datetime +import psycopg2 + +BASE_URL = os.environ["BASE_URL"] + +class DbContextManager: + def __init__(self, db: "Db"): + self.db = db + + def __enter__(self): + self.cursor = self.db.get_cursor() + return (self.db.conn, self.cursor) + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.cursor: + self.cursor.close() + # Return False to propagate exceptions, True to suppress them + return False + +class Db: + MAX_RETRIES = 5 + + def __init__(self): + self.connect() + + def connect(self): + self.conn = psycopg2.connect( + host=os.getenv('DB_HOST', 'localhost'), + database=os.getenv('DB_DATABASE', 'postgres'), + port=os.getenv('DB_PORT', '5432'), + user=os.getenv('DB_USER', 'postgres'), + password=os.environ['DB_PASSWORD']) + + def get_cursor(self): + for _ in range(self.MAX_RETRIES): + try: + return self.conn.cursor() + except psycopg2.OperationalError: + self.connect() + + raise Exception(f"Failed to connect to database after {Db.MAX_RETRIES} retries.") + + def cursor(self): + return DbContextManager(db=self) + +db = Db() + +@dataclass +class Vote: + id: str + poll_id: str + choice_id: str + voter_name: str + +@dataclass +class Choice: + id: str + poll_id: str + start_datetime: datetime.datetime + end_datetime: datetime.datetime + votes: list[Vote] + + def start_datetime_notz(self): + return self.start_datetime.replace(tzinfo=None) + + def end_datetime_notz(self): + return self.end_datetime.replace(tzinfo=None) + +@dataclass +class Poll: + id: str + title: str + description: str | None + pub_date: datetime.datetime + author_name: str + author_email: str | None + choices: list[Choice] + manage_code: str + + def share_url(self): + return f"{BASE_URL}/poll/{self.id}" + + def manage_url(self): + return f"{BASE_URL}/manage/{self.manage_code}" + +def tuple_to_poll(poll_t: tuple) -> Poll: + return Poll( + id=poll_t[0], + title=poll_t[1], + description=poll_t[2], + pub_date=poll_t[3], + author_name=poll_t[4], + author_email=poll_t[5], + manage_code=poll_t[6], + choices=[] + ) + +def tuple_to_choice(choice_t: tuple) -> Choice: + return Choice( + id=choice_t[0], + poll_id=choice_t[1], + start_datetime=choice_t[2], + end_datetime=choice_t[3], + votes=[] + ) + +def tuple_to_vote(vote_t: tuple) -> Vote: + return Vote( + id=vote_t[0], + poll_id=vote_t[1], + choice_id=vote_t[2], + voter_name=vote_t[3], + ) + +def get_poll(id: str): + with db.cursor() as (conn, cur): + try: + cur.execute("SELECT * FROM polls WHERE id = %s", (id,)) + poll_t = cur.fetchone() + if poll_t is None: + return None + + poll = tuple_to_poll(poll_t) + cur.execute("SELECT * FROM choices " + "WHERE poll_id = %s " + "ORDER BY start_datetime", (id,)) + choice_ts = cur.fetchall() + + cur.execute("SELECT * FROM votes " + "WHERE poll_id = %s " + "ORDER BY voter_name", (id,)) + vote_ts = cur.fetchall() + + for choice_t in choice_ts: + choice = tuple_to_choice(choice_t) + + for vote_t in vote_ts: + vote = tuple_to_vote(vote_t) + if vote.choice_id == choice.id: + choice.votes.append(vote) + + poll.choices.append(choice) + + conn.commit() + return poll + except Exception as e: + conn.rollback() + raise e + +def create_poll(title: str, + description: str, + author_name: str, + author_email: str, + choices: list[Choice]): + + with db.cursor() as (conn, cur): + try: + cur.execute("INSERT INTO polls (title, description, author_name, author_email)" + "VALUES (%s, %s, %s, %s) RETURNING *", + (title, description, author_name, author_email)) + poll_t = cur.fetchone() + + if poll_t is None: + raise Exception("Failed to create poll") + + poll = tuple_to_poll(poll_t) + + for choice in choices: + cur.execute("INSERT INTO choices (poll_id, start_datetime, end_datetime)" + "VALUES (%s, %s, %s)", + (poll.id, choice.start_datetime, choice.end_datetime)) + + conn.commit() + return poll + except Exception as e: + conn.rollback() + raise e + +def vote_poll(poll_id: str, voter_name: str, choice_ids: list[str]): + with db.cursor() as (conn, cur): + try: + for choice_id in choice_ids: + cur.execute("INSERT INTO votes (poll_id, voter_name, choice_id)" + "VALUES (%s, %s, %s)", + (poll_id, voter_name, choice_id)) + + conn.commit() + except Exception as e: + conn.rollback() + raise e + +def apply_migration(migration_sql: str) -> None: + with db.cursor() as (conn, cur): + try: + cur.execute(migration_sql) + conn.commit() + except Exception as e: + conn.rollback() + raise e + +def get_poll_by_code(code: str) -> Poll | None: + with db.cursor() as (conn, cur): + try: + cur.execute("SELECT id FROM polls WHERE manage_code = %s", (code,)) + poll_t = cur.fetchone() + if poll_t is None: + return None + + poll = get_poll(poll_t[0]) + conn.commit() + return poll + except Exception as e: + conn.rollback() + raise e + +def update_poll_info( + code, + title, + description, + author_name, + author_email, + ) -> None: + with db.cursor() as (conn, cur): + try: + cur.execute("UPDATE polls SET title = %s, description = %s, author_name = %s, author_email = %s " + "WHERE manage_code = %s", + (title, description, author_name, author_email, code)) + conn.commit() + except Exception as e: + conn.rollback() + raise e + +def add_choice_to_poll( + code, + start_datetime, + end_datetime, + ) -> None: + poll = get_poll_by_code(code) + if poll is None: + raise Exception(f"Poll not found for code: {code}") + + with db.cursor() as (conn, cur): + try: + cur.execute("INSERT INTO choices (poll_id, start_datetime, end_datetime)" + "VALUES (%s, %s, %s)", + (poll.id, start_datetime, end_datetime)) + conn.commit() + except Exception as e: + conn.rollback() + raise e + +def delete_choice(choice_id: str) -> None: + with db.cursor() as (conn, cur): + try: + cur.execute("DELETE FROM choices WHERE id = %s", (choice_id,)) + cur.execute("DELETE FROM votes WHERE choice_id = %s", (choice_id,)) + conn.commit() + except Exception as e: + conn.rollback() + raise e + +def get_polls_by_codes(codes: list[str]) -> list[Poll]: + with db.cursor() as (conn, cur): + try: + codes_t = tuple(codes) + cur.execute("SELECT * FROM polls WHERE manage_code IN %s" + "ORDER BY pub_date DESC", (codes_t,)) + poll_ts = cur.fetchall() + polls = [tuple_to_poll(poll_t) for poll_t in poll_ts] + + conn.commit() + return polls + except Exception as e: + conn.rollback() + raise e + +def delete_poll(code: str) -> None: + with db.cursor() as (conn, cur): + try: + cur.execute("DELETE FROM polls WHERE manage_code = %s", (code,)) + conn.commit() + except Exception as e: + conn.rollback() + raise e diff --git a/migrations/0000_initial.sql b/migrations/0000_initial.sql new file mode 100644 index 0000000..8e9f1ae --- /dev/null +++ b/migrations/0000_initial.sql @@ -0,0 +1,28 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS polls ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + title TEXT NOT NULL, + description TEXT, + pub_date TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + author_name TEXT NOT NULL, + author_email TEXT, + manage_code uuid DEFAULT uuid_generate_v4() +); + +CREATE TABLE IF NOT EXISTS choices ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + poll_id uuid NOT NULL, + start_datetime TIMESTAMP NOT NULL, + end_datetime TIMESTAMP NOT NULL, + FOREIGN KEY (poll_id) REFERENCES polls (id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS votes ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + poll_id uuid NOT NULL, + choice_id uuid NOT NULL, + voter_name TEXT NOT NULL, + FOREIGN KEY (choice_id) REFERENCES choices (id) ON DELETE CASCADE, + FOREIGN KEY (poll_id) REFERENCES polls (id) ON DELETE CASCADE +); diff --git a/migrations/0001_indices.sql b/migrations/0001_indices.sql new file mode 100644 index 0000000..42007ee --- /dev/null +++ b/migrations/0001_indices.sql @@ -0,0 +1,3 @@ +CREATE INDEX IF NOT EXISTS idx_votes_poll_id_voter_name ON votes (poll_id, voter_name); +CREATE INDEX IF NOT EXISTS idx_choices_poll_id_start_datetime ON choices (poll_id, start_datetime); +CREATE INDEX IF NOT EXISTS idx_votes_choice_id ON votes (choice_id); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a1b0023 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +blinker==1.7.0 +click==8.1.7 +Flask==3.0.2 +gunicorn==21.2.0 +itsdangerous==2.1.2 +Jinja2==3.1.3 +MarkupSafe==2.1.5 +packaging==23.2 +psycopg2-binary==2.9.9 +python-dotenv==1.0.1 +Werkzeug==3.0.1 diff --git a/scripts/container_entrypoint.sh b/scripts/container_entrypoint.sh new file mode 100755 index 0000000..9f8dcf2 --- /dev/null +++ b/scripts/container_entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -euxo pipefail + +python apply_migrations.py +gunicorn --bind "0.0.0.0:8000" -w 4 app:app diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..e34660b --- /dev/null +++ b/static/styles.css @@ -0,0 +1,133 @@ +html { + font-family: "Helvetica Neue", "Arial", sans-serif; + font-size: 16px; +} + +body { + padding: 10px; + max-width: 1000px; + margin: 0 auto; +} + +button, +input[type="submit"] { + cursor: pointer; + font-size: 16px; + border-radius: 4px; + text-shadow: none; + box-shadow: none; + padding: 5px 8px; + border: 0; + background-color: rgb(63, 122, 248); + color: white; +} + +button.red, +input[type="submit"].red { + background-color: #d42f2f; +} + +button.green, +input[type="submit"].green { + background-color: #4caf50; +} + +button.yellow, +input[type="submit"].yellow { + background-color: #ff9800; +} + +input[type="text"], +input[type="datetime-local"] { + font-size: 16px; + color: black; +} + +input[type="text"]:disabled, +input[type="datetime-local"]:disabled { + color: #666; +} + +.contrast { + color: #fa8e35; +} + +th, td { + padding-left: 0; + padding-right: 5px; + min-width: 100px; +} + +table { + margin-left: -2px; +} + +.vote-table th, +.vote-table td { + text-align: center; + padding: 7px 5px; +} + +.vote-table th span { + display: block; +} + +.vote-table th span i { + font-weight: normal; +} + +.vote-table td:first-child { + text-align: left; + padding-left: 5px; +} + +.vote-table tr:last-child td:first-child { + padding-left: 0; +} + +.vote-table tr td:nth-child(even), +.vote-table tr th:nth-child(even) { + background-color: #f2f2f2; /* Light grey background */ +} + +.vote-table tr:nth-child(even), +.vote-table tr:nth-child(even) { + background-color: #f8f6ff; /* Light grey background */ +} + +.danger-zone { + margin-top: 40px; +} + +.manage-table th { + text-align: left; +} + +.poll-form td:last-child, +.manage-table td:last-child { + display: flex; + flex-flow: row; + align-items: center; +} + +.poll-form td:last-child > *, +.manage-table td:last-child > * { + flex: 1; +} + +.manage-table td:last-child input[type="submit"] { + max-width: 70px; +} + +.display-mode-section { + margin-bottom: 30px; +} + +.display-mode-section input[type="submit"] { + margin-left: 10px; +} + +.vote-list label, +input[type="checkbox"] { + cursor: pointer; +} diff --git a/templates/base.html.j2 b/templates/base.html.j2 new file mode 100644 index 0000000..066c39e --- /dev/null +++ b/templates/base.html.j2 @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> +<head> + <title>diddle 👉👈</title> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> + <link rel="stylesheet" href="/static/styles.css"></link> +</head> +<body> + <h1><a href="/">diddle 👉👈</a></h1> + {% block content %}{% endblock %} +</body> +</html> diff --git a/templates/error.html.j2 b/templates/error.html.j2 new file mode 100644 index 0000000..2d0dc19 --- /dev/null +++ b/templates/error.html.j2 @@ -0,0 +1,6 @@ +{% extends "base.html.j2" %} + +{% block content %} +<h2>Error</h2> +{{ error }} +{% endblock %} diff --git a/templates/index.html.j2 b/templates/index.html.j2 new file mode 100644 index 0000000..f84b428 --- /dev/null +++ b/templates/index.html.j2 @@ -0,0 +1,40 @@ +{% extends "base.html.j2" %} + +{% block content %} +<h2>Create new diddle</h2> +<form class="poll-form" action="/poll/create" method="post"> + <table> + <tbody> + <tr> + <td><label for="title">Title</label></td> + <td><input type="text" name="title" required></td> + </tr> + <tr> + <td><label for="description">Description (optional)</label></td> + <td><textarea name="description"></textarea></td> + </tr> + <tr> + <td><label for="author_name">Your name</label></td> + <td><input type="text" name="author_name" required></td> + </tr> + <tr> + <td><label for="author_email">Your email (optional)</label></td> + <td><input type="text" name="author_email"></td> + </tr> + </tbody> + </table> + + <p>You can add options after submitting.</p> + <input class="green" type="submit" value="Create"> +</form> + +{% if created_polls | length != 0 %} +<h3>My diddles</h3> +<ul> + {% for poll in created_polls %} + <li><a href="/manage/{{ poll.manage_code }}">{{ poll.title }}</a></li> + {% endfor %} +</ul> +{% endif %} + +{% endblock %} diff --git a/templates/manage.html.j2 b/templates/manage.html.j2 new file mode 100644 index 0000000..82f67ea --- /dev/null +++ b/templates/manage.html.j2 @@ -0,0 +1,119 @@ +{% extends "base.html.j2" %} + +{% block content %} + +<h2>Managing poll <span class="contrast">{{ poll.title }}</span> by <i>{{ poll.author_name }}</i></h2> + +<p> + Share this URL with your peers:<br> + <a href="{{ poll.share_url() }}">{{ poll.share_url() }}</a> +</p> +<p> + <strong>Note!</strong> You can only edit this poll if you have this URL or use the same browser session:<br> + <a href="{{ poll.manage_url() }}">{{ poll.manage_url() }}</a> +</p> + +<form class="poll-form" action="/manage/{{ poll.manage_code }}/update_info" method="post"> + <table> + <tbody> + <tr> + <td><label for="title">Title</label></td> + <td><input type="text" name="title" value="{{ poll.title }}" required></td> + </tr> + <tr> + <td><label for="description">Description (optional)</label></td> + <td><textarea name="description" value="{{ poll.description }}"></textarea></td> + </tr> + <tr> + <td><label for="author_name">Your name</label></td> + <td><input type="text" name="author_name" value="{{ poll.author_name }}" required></td> + </tr> + <tr> + <td><label for="author_email">Your email (optional)</label></td> + <td><input type="text" name="author_email" value="{{ poll.author_email }}"></td> + </tr> + </tbody> + </table> + <br> + <input type="submit" value="Update"> +</form> + +<div> + <h3>Options</h3> +</div> + +<table class="manage-table"> + <thead> + <tr> + <th>Start time</th> + <th>End time</th> + <th></th> + </tr> + </thead> + <tbody> + {% for choice in poll.choices %} + <form action="/manage/{{ poll.manage_code }}/delete_choice/{{ choice.id }}" method="post"> + <tr> + <td> + <input type="datetime-local" name="start_datetime_{{ choice.id }}" value="{{ choice.start_datetime_notz() }}" disabled + {% if last_choice_id == choice.id %}id="last-start-datetime"{% endif %}> + </td> + <td> + <input type="datetime-local" name="end_datetime_{{ choice.id }}" value="{{ choice.end_datetime_notz() }}" disabled + {% if last_choice_id == choice.id %}id="last-end-datetime"{% endif %}> + </td> + <td> + <input class="red" type="submit" value="Delete"> + </td> + </tr> + </form> + {% endfor %} + <form action="/manage/{{ poll.manage_code }}/add_choice" method="post"> + <tr> + <td> + <input type="datetime-local" name="start_datetime" required> + </td> + <td> + <input type="datetime-local" name="end_datetime" required> + </td> + <td> + <input class="green" type="submit" value="Add"> + </td> + </tr> + </form> + </tbody> +</table> + +<div class="danger-zone"> + <h3>Danger zone</h3> + <form action="/manage/{{ poll.manage_code }}/delete" method="post"> + <!-- Empty forms make Lynx use HTTP method GET incorrectly, so we add a useless field --> + <input type="hidden" name="poll_id" value="{{ poll.id }}"> + <input class="red" type="submit" value="Delete poll"> + </form> +</div> + +<script> +const hour = 1000 * 60 * 60; + +const startDatetimeInput = document.querySelector('input[name="start_datetime"]'); +const endDatetimeInput = document.querySelector('input[name="end_datetime"]'); + +const lastStartDatetimeInput = document.getElementById('last-start-datetime'); +const lastEndDatetimeInput = document.getElementById('last-end-datetime'); + +if (lastStartDatetimeInput !== null && startDatetimeInput.valueAsDate === null) { + startDatetimeInput.valueAsDate = new Date(lastStartDatetimeInput.valueAsDate.getTime() + hour); +} + +startDatetimeInput.addEventListener('blur', onStartDatetimeBlur); + +function onStartDatetimeBlur(event) { + const endDatetimeInput = document.querySelector('input[name="end_datetime"]'); + if (endDatetimeInput.valueAsDate === null) { + endDatetimeInput.valueAsDate = new Date(event.target.valueAsDate.getTime() + hour); + } +} +</script> + +{% endblock %}
\ No newline at end of file diff --git a/templates/poll.html.j2 b/templates/poll.html.j2 new file mode 100644 index 0000000..e0ede1f --- /dev/null +++ b/templates/poll.html.j2 @@ -0,0 +1,34 @@ +{% extends "base.html.j2" %} + +{% block content %} + +<h2><span class="contrast">{{ poll.title }}</span> by <i>{{ poll.author_name }}</i></h2> +{% if poll.description %} + <p>{{ poll.description }}</p> +{% endif %} + +{% if poll.choices | length == 0 %} +<p>No options available.</p> +{% else %} + +<div class="display-mode-section"> + <form action="/options/toggle_display_mode" method="post"> + <input type="hidden" name="poll_id" value="{{ poll.id }}"> + <label> + Display mode: <strong>{{ display_mode }}</strong> + <input type="submit" value="Toggle"> + </label> + </form> +</div> + +<form action="/poll/{{ poll.id }}/vote" method="post"> + {% if display_mode == "table" %} + {% include "poll_vote_table.html.j2" %} + {% else %} + {% include "poll_vote_list.html.j2" %} + {% endif %} +</form> +{% endif %} + + +{% endblock %} diff --git a/templates/poll_confirm_delete.html.j2 b/templates/poll_confirm_delete.html.j2 new file mode 100644 index 0000000..3a6bc83 --- /dev/null +++ b/templates/poll_confirm_delete.html.j2 @@ -0,0 +1,13 @@ +{% extends "base.html.j2" %} + +{% block content %} +<h2>Confirm deletion of <span class="contrast">{{ poll.title }}</span></h2> + +<p>Are you sure you want to delete {{ poll.title }}?</p> + +<form action="/manage/{{ poll.manage_code }}/confirm_delete" method="post"> + <!-- Empty forms make Lynx use HTTP method GET incorrectly, so we add a useless field --> + <input type="hidden" name="poll_id" value="{{ poll.id }}"> + <input class="red" type="submit" value="Yes, delete it"> +</form> +{% endblock %} diff --git a/templates/poll_vote_list.html.j2 b/templates/poll_vote_list.html.j2 new file mode 100644 index 0000000..37b9406 --- /dev/null +++ b/templates/poll_vote_list.html.j2 @@ -0,0 +1,34 @@ +<div class="vote-list"> + {% for choice in choices %} + <div> + <label for="choice_{{ choice.id }}"> + <input type="checkbox" name="choice_{{ choice.id }}" id="choice_{{ choice.id }}"> + <strong> + {{ choice.start_datetime.strftime("%a %d.%m") }} + <span> + {% if now.year != choice.start_datetime.year %}.{{choice.start_datetime.year}}{% endif %} + </span> + <span> + {{ choice.start_datetime.strftime("%H:%M") }} – {{ choice.end_datetime.strftime("%H:%M") }} + </span> + </strong> + <span> + <i>{{ choice.votes | length }} votes</i> + </span> + <div> + {% if choice.votes | length != 0 %} + Voted by: {% for vote in choice.votes %}{{ vote.voter_name }}{% if not loop.last %}, {% endif %}{% endfor %} + {% endif %} + <div> + </label> + </div> + <p></p> + {% endfor %} +</div> + +<div> + <label for="voter_name" hidden>Your name</label> + <input type="text" name="voter_name" placeholder="Your name" required> + <input class="green" type="submit" value="Submit"> +</div> + diff --git a/templates/poll_vote_table.html.j2 b/templates/poll_vote_table.html.j2 new file mode 100644 index 0000000..bfba968 --- /dev/null +++ b/templates/poll_vote_table.html.j2 @@ -0,0 +1,51 @@ +<table class="vote-table"> + <tr> + <th></th> + {% for choice in choices %} + <th> + {{ choice.start_datetime.strftime("%a %d.%m") }} + <span> + {% if now.year != choice.start_datetime.year %}.{{choice.start_datetime.year}}{% endif %} + </span> + <span> + {{ choice.start_datetime.strftime("%H:%M") }} – {{ choice.end_datetime.strftime("%H:%M") }} + </span> + <span> + <i>{{ choice.votes | length }} votes</i> + </span> + </th> + {% endfor %} + </tr> + {% for voter in choices_by_voter %} + <tr> + <td>{{ voter.name }}</td> + {% for voted in voter.votes %} + {% if voted %} + <td> + <input type="checkbox" checked disabled> + </td> + {% else %} + <td> + <input type="checkbox" disabled> + </td> + {% endif %} + {% endfor %} + </tr> + {% endfor %} + + <!-- Add a row for the current user --> + <tr> + <td> + <label for="voter_name" hidden>Your name</label> + <input type="text" name="voter_name" placeholder="Your name" required> + </td> + {% for choice in choices %} + <td> + <input type="checkbox" name="choice_{{ choice.id }}"> + </td> + {% endfor %} + </tr> + <!-- End of the row for the current user --> +</table> + +<input class="green" type="submit" value="Submit"> |
