diff --git a/Dockerfile b/Dockerfile index 177b8c0258c29f77f7dd74509fc2f54ee28ff690..f4221d669d7a5d80391f30f353be7ec7516c25f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN apt-get install nodejs && rm -rf /var/lib/apt/lists/* COPY . . -RUN pip install -r requirements/base.txt -r requirements/prod.txt +RUN pip install -r requirements/base.txt -r requirements/production.txt RUN npm install RUN npm run build @@ -16,14 +16,14 @@ RUN npm run build RUN DATABASE_URL=postgres://x/x \ SECRET_KEY=x \ ALLOWED_HOSTS=x \ - python manage.py collectstatic --noinput --settings=rybicka.settings.prod + python manage.py collectstatic --noinput --settings=rybicka.settings.production RUN bash -c "adduser --disabled-login --quiet --gecos app app && \ chmod -R o+r /app/ && \ chmod o+x /app/run.sh" USER app -ENV DJANGO_SETTINGS_MODULE "rybicka.settings.prod" +ENV DJANGO_SETTINGS_MODULE "rybicka.settings.production" EXPOSE 8000 diff --git a/Makefile b/Makefile index 4dbde64bfe584eb70a83c33d63fe352f99d969a3..8a03c83d0073fdb0ce5c1cb8b3b7cac8e3bad2da 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ PYTHON = python VENV = .venv PORT = 8012 -SETTINGS = rybicka.settings.prod +SETTINGS = rybicka.settings.dev .PHONY: help venv install build run shell migrations migrate @@ -26,7 +26,7 @@ venv: .venv/bin/python ${PYTHON} -m venv ${VENV} install: venv - ${VENV}/bin/pip install -r requirements/base.txt -r requirements/prod.txt + ${VENV}/bin/pip install -r requirements/base.txt -r requirements/production.txt ${VENV}/bin/nodeenv --python-virtualenv --node=19.3.0 ${VENV}/bin/npm install diff --git a/env.example b/env.example index 5b8cb36653245921a245bda6313b083dc9a29e64..3b703bebba92d99e88b0ba05a04d7903be5c1037 100644 --- a/env.example +++ b/env.example @@ -2,5 +2,8 @@ DATABASE_URL="postgresql://rybicka:rybicka@localhost:5432/rybicka" SECRET_KEY="%@=^sip3=tqn6d_-xvvidc1@-t0t3&*kab@vr4c4" +CHOBOTNICE_API_URL="https://chobotnice.pirati.cz/graphql/" +CHOBOTNICE_RV_GID="R3JvdXBUeXBlOjYyNQ==" + # Production settings -ALLOWED_HOSTS="nastroje.pirati.cz" +ALLOWED_HOSTS="tools.pirati.cz" diff --git a/member_group_size_calc/templates/member_group_size_calc/index.html b/member_group_size_calc/templates/member_group_size_calc/index.html index 88c19886ce94933c6a44ae689f589a33717bf8d8..6ee5581a79a89f92fe6ce80b76cd41e474a7afca 100644 --- a/member_group_size_calc/templates/member_group_size_calc/index.html +++ b/member_group_size_calc/templates/member_group_size_calc/index.html @@ -7,6 +7,11 @@ {% block description %}Výpočet velikosti skupiny členů podle jednoacího řádu.{% endblock %} {% block head %} + <link + rel="stylesheet" + href="https://styleguide.pirati.cz/2.11.x/css/styles.css" + > + {% render_bundle "member_group_size_calc" %} {% endblock %} @@ -15,17 +20,17 @@ <h1 class="text-6xl font-bebas mb-5">Kalkulačka velikosti skupiny členů</h1> <div class="bg-amber-100 p-4 flex flex-row items-center gap-4 mb-4 lg:w-[768px] md:w-full"> - <i class="fa-solid fa-lightbulb text-3xl text-amber-800"></i> + <i class="ico--book text-3xl text-amber-800"></i> <div class="text-amber-800"> Tato kalkulačka slouží pro výpočet skupiny členů podle <a class="underline text-amber-900" - href="https://wiki.pirati.cz/rules/jdr" + href="https://wiki.pirati.cz/rules/jdr#skupina_clenu" >Jednacího řádu</a>. </div> </div> <div class="bg-gray-100 p-4 flex flex-row items-start gap-4 mb-4 lg:w-[768px] md:w-full"> - <i class="fa-solid fa-circle-info text-3xl text-gray-800"></i> + <i class="ico--info text-3xl text-gray-800"></i> <div class="text-gray-800"> <h2 class="text-lg font-bold mb-3">Jednací řád celostátního fóra</h2> @@ -123,7 +128,7 @@ </div> <p class="font-light"> - <i>Vypočtené hodnoty jsou zaokrouhleny na celé číslo nahoru.</i> + <i>Vypočtené hodnoty se zaokrouhlují na celé osoby (nahoru).</i> </p> </main> {% endblock %} diff --git a/package.json b/package.json index 62c00d323ff51eff9b20b58024cac987df959a84..4a07c0ecbe08e9ffec6d3ceb929877ce237b3726 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,10 @@ "description": "", "private": true, "dependencies": { - "@fortawesome/fontawesome-free": "^6.2.1", "css-loader": "^6.7.3", "jquery": "^3.6.3", + "js-cookie": "^3.0.1", + "select2": "^4.1.0-rc.0", "style-loader": "^3.3.1", "tailwindcss": "^3.2.4", "webpack": "^5.75.0", diff --git a/requirements/base.txt b/requirements/base.txt index 150dea4b81c612a8cca7f11e79a8ebfaec5a9ce5..fe6b002554d53683818bd6e20d728659bead4bb7 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,6 +1,9 @@ Django==4.1.5 django-database-url==1.0.3 django-environ==0.9.0 -psycopg2-binary==2.9.5 django-webpack-loader==1.8.0 +django-http-exceptions==1.4.0 +gql[requests]==3.4.0 nodeenv==1.7.0 +psycopg2-binary==2.9.5 +requests==2.28.2 diff --git a/requirements/prod.txt b/requirements/production.txt similarity index 100% rename from requirements/prod.txt rename to requirements/production.txt diff --git a/rv_voting_calc/templates/member_group_size_calc/index.html b/rv_voting_calc/templates/member_group_size_calc/index.html deleted file mode 100644 index 4a0ef689e80cd0a66d76fa42a485ff28f1f84f10..0000000000000000000000000000000000000000 --- a/rv_voting_calc/templates/member_group_size_calc/index.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "shared/base.html" %} - -{% load render_bundle from webpack_loader %} - -{% block title %}Kalkulačka hlasování RV{% endblock %} -{% block header_name %}Hlasování RV{% endblock %} -{% block description %}TODO - Popis{% endblock %} - -{% block head %} - {% render_bundle "rv_voting_calc" %} -{% endblock %} - -{% block content %} - <main> - - </main> -{% endblock %} diff --git a/rv_voting_calc/templates/rv_voting_calc/combined_steps.html b/rv_voting_calc/templates/rv_voting_calc/combined_steps.html new file mode 100644 index 0000000000000000000000000000000000000000..394215e538e4840986e5b5e7302a7771bbcaacbe --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/combined_steps.html @@ -0,0 +1,15 @@ +{% comment %} + It's fine to pass in |safe here, as the template variables have + already been sanitized once, as each step got rendered. +{% endcomment %} + +{{ html_steps|safe }} + +<div id="md-steps" class="hidden"> + {% for md_step in md_steps %} +{{ md_step }}{% if not forloop.last %} + +--- + +{% endif %}{% endfor %} +</div> diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/found_popularity_winner.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/found_popularity_winner.html new file mode 100644 index 0000000000000000000000000000000000000000..09738a53692c9e0ade2e236a00f5ff4a20fa0731 --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/found_popularity_winner.html @@ -0,0 +1,3 @@ +<li class="bg-green-200 drop-shadow-md p-4 font-semibold text-center text-xl"> + Možnost {{ option_key }} vyhrává s {{ option.vote_count }} hlasy. +</li> diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/initial_sort.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/initial_sort.html new file mode 100644 index 0000000000000000000000000000000000000000..097d7606bc6f0f800cd4c3e79785fdaa9026fc47 --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/initial_sort.html @@ -0,0 +1,11 @@ +<li class="bg-gray-100 drop-shadow-md p-4"> + <div class="pb-2 mb-2 border-b border-gray-300"> + <h2 class="text-2xl font-semibold font-bebas">Počáteční rozdělení</h2> + <small class="text-sm"> + <strong>{{ total_ticket_count }}</strong> lístků, + hranice pro nadpoloviční podporu <strong>{{ has_support_treshold }}</strong> + </small> + </div> + + {% include "rv_voting_calc/includes/option_list.html" with options=options options_by_member=options_by_member show_proposers=True show_has_support=True %} +</li> diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/no_winner.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/no_winner.html new file mode 100644 index 0000000000000000000000000000000000000000..bb9560d3854f8013c2868f1c0005164684b5fb62 --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/no_winner.html @@ -0,0 +1,3 @@ +<li class="bg-red-200 drop-shadow-md p-4 font-semibold text-center text-xl"> + Ani jedna možnost nevyhrává. +</li> diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/removed_option.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/removed_option.html new file mode 100644 index 0000000000000000000000000000000000000000..062e07c61a4e3f49d8e3c68e80fe29b8b8d97acb --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/removed_option.html @@ -0,0 +1,11 @@ +{% load index %} + +<li class="bg-gray-100 drop-shadow-md p-4"> + <div class="pb-2 mb-3 border-b border-gray-300"> + <h2 class="text-2xl font-semibold font-bebas">{{ iteration|add:1 }}. vyřazovací kolo</h2> + </div> + <p class="mb-2 text-red-600 font-semibold"> + Možnost {{ option_key }} bude {% if randomly %}losováním{% else %}kvůli nepopularitě{% endif %} odstraněna + </p> + {% include "rv_voting_calc/includes/option_list.html" with options=options options_by_member=options_by_member show_proposers=True show_marked_for_deletion=True %} +</li> diff --git a/rv_voting_calc/templates/rv_voting_calc/html_steps/with_support.html b/rv_voting_calc/templates/rv_voting_calc/html_steps/with_support.html new file mode 100644 index 0000000000000000000000000000000000000000..6f3367a1c992d8cb47f9a8b1a1181d980756930b --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/html_steps/with_support.html @@ -0,0 +1,11 @@ +<li class="bg-gray-100 drop-shadow-md p-4"> + <div class="pb-2 mb-2 border-b border-gray-300"> + <h2 class="text-2xl font-semibold font-bebas">Po vyřazení možností bez nadpoloviční podpory</h2> + </div> + + {% if options_with_support_count != 0 %} + {% include "rv_voting_calc/includes/option_list.html" with options=options options_by_member=options_by_member %} + {% else %} + <p class="text-gray-800 mt-3">Žádné možnosti nemají nadpoloviční podporu.</p> + {% endif %} +</li> diff --git a/rv_voting_calc/templates/rv_voting_calc/includes/option_list.html b/rv_voting_calc/templates/rv_voting_calc/includes/option_list.html new file mode 100644 index 0000000000000000000000000000000000000000..1e77bca301e571b13d271aa0f3572d5381b7a8ff --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/includes/option_list.html @@ -0,0 +1,38 @@ +{% load index %} + +<ul class="flex flex-col gap-2"> + {% for option_key, option in options.items %} + {% if not option.marked_for_deletion or show_marked_for_deletion %} + {% if option.has_support or show_has_support %} + <h3 class="flex gap-2 items-end text-xl font-semibold {% if option.marked_for_deletion or not option.has_support %}text-red-600{% endif %}"> + {{ option_key }} + <span class="text-sm {% if option.marked_for_deletion or not option.has_support %}text-red-600{% else %}text-gray-600{% endif %}"> + {{ option.ticket_count }} lístků, {{ option.vote_count }} hlasů + </span> + {% if show_has_support and not option.has_support %} + <span class="text-sm text-red-600">(Nemá nadpoloviční podporu)</span> + {% endif %} + </h3> + + {% if option.ticket_votes|length != 0 %} + <ul class="flex gap-3 mt-1 flex-wrap"> + {% for vote in option.ticket_votes %} + {% if not vote.hidden %} + <li class="flex"> + <div class="px-4 py-2 {% if not option.marked_for_deletion and option.has_support %}bg-gray-300{% else %}bg-red-300{% endif %}"> + {{ rv_members|index:vote.member|index:"displayName" }} + </div> + <ul class="px-4 py-2 flex gap-1 {% if not option.marked_for_deletion and option.has_support %}bg-gray-200{% else %}bg-red-200{% endif %}"> + {% for option in options_by_member|index:vote.member %} + <li>{{ option }}{% if not forloop.last %}, {% endif %}</li> + {% endfor %} + </ul> + </li> + {% endif %} + {% endfor %} + </ul> + {% endif %} + {% endif %} + {% endif %} + {% endfor %} +</ul> diff --git a/rv_voting_calc/templates/rv_voting_calc/includes/option_list.md b/rv_voting_calc/templates/rv_voting_calc/includes/option_list.md new file mode 100644 index 0000000000000000000000000000000000000000..d605fed05c80226347bbae7a1512f567d7af5f88 --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/includes/option_list.md @@ -0,0 +1,6 @@ +{% load index %} + +{% for option_key, option in options.items %}{% if not option.marked_for_deletion or show_marked_for_deletion %}{% if option.has_support or show_has_support %} +- **Možnost {{ option_key }}** - {{ option.ticket_count }} lístků, {{ option.vote_count }} hlasů.{% if show_has_support and not option.has_support %} *(Nemá nadpoloviční podporu)*{% endif %}{% if option.marked_for_deletion or not option.has_support %} *(K odstranění)*{% endif %}{% if option.ticket_votes|length != 0 %} Volby: {% for vote in option.ticket_votes %}{% if not vote.hidden %} + - **{{ rv_members|index:vote.member|index:"displayName" }}** - přijatelné možnosti:{% for option in options_by_member|index:vote.member %} + - {{ option }}{% endfor %}{% endif %}{% endfor %}{% endif %}{% endif %}{% endif %}{% endfor %} diff --git a/rv_voting_calc/templates/rv_voting_calc/index.html b/rv_voting_calc/templates/rv_voting_calc/index.html new file mode 100644 index 0000000000000000000000000000000000000000..367cc0921908d88d253eb13a1222d61f1c15690f --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/index.html @@ -0,0 +1,107 @@ +{% extends "shared/base.html" %} + +{% load render_bundle from webpack_loader %} + +{% block title %}Kalkulačka hlasování RV{% endblock %} +{% block header_name %}Hlasování RV{% endblock %} +{% block description %}TODO - Popis{% endblock %} + +{% block head %} + {% render_bundle "rv_voting_calc" %} + + <link + href="https://styleguide.pirati.cz/2.10.x/css/styles.css" + rel="stylesheet" + media="all" + > + <link + href="https://styleguide.pirati.cz/2.10.x/css/pattern-scaffolding.css" + rel="stylesheet" + media="all" + > +{% endblock %} + +{% block content %} + <main> + <h1 class="text-6xl font-bebas mb-5">Kalkulačka hlasování RV</h1> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-7 mb-5 pb-4 items-center border-b border-gray-100"> + <h2 class="text-2xl font-bebas">Hlasy členů</h2> + + <div class="flex items-center gap-3 justify-between"> + <h2 class="text-2xl font-bebas">Výsledky</h2> + <button + class="btn disabled:cursor-progress" + id="count-votes" + {% if not options_by_member %}disabled{% endif %} + > + <div class="btn__body"> + Vypočítat + </div> + </button> + </div> + </div> + + <div class="grid grid-cols-1 md:grid-cols-2 gap-7"> + <ul class="flex flex-col gap-2"> + {% for member in rv_members %} + <li class="flex gap-4 items-center"> + <div class="basis-56 flex items-center"> + <i class="ico--user text-xl mr-2"></i> + {{ member.displayName }} + </div> + <select + id="{{ member.username }}-selection" + class="__vote-selection grow w-full" + multiple="multiple" + ></select> + </li> + {% endfor %} + </ul> + + <div> + <ul + class="flex flex-col gap-5" + id="result" + > + + </ul> + <div class="mt-4 flex gap-4 md:justify-end justify-center"> + <a + class="hidden btn" + id="permalink" + href="" + ><div class="btn__body">Permalink</div></a> + <a + class="hidden btn" + id="download-log" + download="kroky.md" + href="" + ><div class="btn__body">Stáhnout log</div></a> + </div> + </div> + </div> + </main> + + {{ keyed_rv_members|json_script:"rv-members" }} + + <script> + const VOTE_CALCULATION_ENDPOINT = "{% url "rv_voting_calc:get_calculated_votes" %}" + + {% if options_by_member %} + {% for member, selected_options in options_by_member.items %} + {% for selected_option in selected_options %} + $(`#${$.escapeSelector("{{ member}}")}-selection`).append( + new Option( + "{{ selected_option }}", + "{{ selected_option }}", + true, + true, + ) + ); + {% endfor %} + $(`#${$.escapeSelector("{{ member}}")}-selection`).trigger("change"); + {% endfor %} + {% endif %} + </script> +{% endblock %} diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/found_popularity_winner.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/found_popularity_winner.md new file mode 100644 index 0000000000000000000000000000000000000000..c01793dd7d57f33b8f4d238823146cdefaba9edb --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/found_popularity_winner.md @@ -0,0 +1 @@ +**Možnost {{ option_key }} vyhrává s {{ option.vote_count }} hlasy.** diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/initial_sort.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/initial_sort.md new file mode 100644 index 0000000000000000000000000000000000000000..f4403f8f5a5c4ba829604b9bdf8b388a26b27858 --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/initial_sort.md @@ -0,0 +1,5 @@ +# Počáteční rozdělení + +**{{ total_ticket_count }}** lístků, hranice pro nadpoloviční podporu **{{ has_support_treshold }}**. + +{% include "rv_voting_calc/includes/option_list.md" with options=options options_by_member=options_by_member show_proposers=True show_has_support=True %} diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/no_winner.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/no_winner.md new file mode 100644 index 0000000000000000000000000000000000000000..01483b3565635af8f55dcc7fa1c9c4b4c4f4235d --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/no_winner.md @@ -0,0 +1 @@ +**Ani jedna možnost nevyhrává.** diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/removed_option.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/removed_option.md new file mode 100644 index 0000000000000000000000000000000000000000..1b22831c74424fafa07e7a60065047f1945e5edb --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/removed_option.md @@ -0,0 +1,7 @@ +{% load index %} + +# {{ iteration|add:1 }}. vyřazovací kolo + +**Možnost {{ option_key }} bude {% if randomly %}losováním{% else %}kvůli nepopularitě{% endif %} odstraněna.** + +{% include "rv_voting_calc/includes/option_list.md" with options=options options_by_member=options_by_member show_proposers=True show_marked_for_deletion=True %} diff --git a/rv_voting_calc/templates/rv_voting_calc/md_steps/with_support.md b/rv_voting_calc/templates/rv_voting_calc/md_steps/with_support.md new file mode 100644 index 0000000000000000000000000000000000000000..b55458fd1b96fd9aa6633dbcdda15694ffd5c4df --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/md_steps/with_support.md @@ -0,0 +1,7 @@ +# Po vyřazení možností bez nadpoloviční podpory + +{% if options_with_support_count != 0 %} + {% include "rv_voting_calc/includes/option_list.md" with options=options options_by_member=options_by_member %} +{% else %} + *Žádné možnosti nemají nadpoloviční podporu.* +{% endif %} diff --git a/rv_voting_calc/templatetags/__init__.py b/rv_voting_calc/templatetags/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/rv_voting_calc/templatetags/index.py b/rv_voting_calc/templatetags/index.py new file mode 100644 index 0000000000000000000000000000000000000000..d20f7e2cdf360fcea86219b84130c7f5499efab5 --- /dev/null +++ b/rv_voting_calc/templatetags/index.py @@ -0,0 +1,10 @@ +from django import template +register = template.Library() + +# https://stackoverflow.com/a/29664945 +# Thanks to WeizhongTu and Bakuutin! + + +@register.filter +def index(indexable, i): + return indexable[i] diff --git a/rv_voting_calc/urls.py b/rv_voting_calc/urls.py index 89f309aab2eddd8d5b04b0c2e8d50fe53fb1f33d..7f3f506150c7e427ab27b88ba35abad1f15f5bf6 100644 --- a/rv_voting_calc/urls.py +++ b/rv_voting_calc/urls.py @@ -5,4 +5,9 @@ from . import views app_name = "rv_voting_calc" urlpatterns = [ path("", views.index, name="index"), + path( + "calculated-votes", + views.get_calculated_votes, + name="get_calculated_votes" + ) ] diff --git a/rv_voting_calc/views.py b/rv_voting_calc/views.py index b40ece24483ddcffd2d2876c18dd5330088e6cbb..05261f0aa3e0004bc1e0bfb808989fbfb6b912f2 100644 --- a/rv_voting_calc/views.py +++ b/rv_voting_calc/views.py @@ -1,9 +1,647 @@ +import base64 +import binascii +import json +import math +import random +import secrets +import urllib.parse + +import gql +import requests + +from gql.transport.exceptions import TransportQueryError +from gql.transport.requests import RequestsHTTPTransport + +from django.conf import settings +from django.http import HttpResponse, JsonResponse from django.shortcuts import render +from django.template.loader import render_to_string +from django_http_exceptions import HTTPExceptions + + +def get_options_by_member(source, rv_members) -> dict: + try: + options_by_member = json.loads(source) + + # The provided votes must be a dictionary with at least 1 key. + if not isinstance(options_by_member, dict) or len(options_by_member) == 0: + raise ValueError + except ValueError: + raise HTTPExceptions.BAD_REQUEST + + rv_members = convert_rv_members_to_dict(rv_members) + + for member, options in options_by_member.items(): + # `member` is not a string + if not isinstance(member, str): + raise HTTPExceptions.BAD_REQUEST + + # `member` is not in the list of known members + if member not in options_by_member: + raise HTTPExceptions.BAD_REQUEST + + # `options` is not a list of 1+ length + if not isinstance(options, list) and len(options) != 0: + raise HTTPExceptions.BAD_REQUEST + + # all items in `options` are strings + for item in options: + if not isinstance(item, str): + raise HTTPExceptions.BAD_REQUEST + + return options_by_member -# Create your views here. def index(request): + rv_members = get_rv_members(request) + keyed_rv_members = convert_rv_members_to_dict(rv_members) + + # Get votes from a possible permalink + options_by_member = request.GET.get("votes") + if options_by_member is not None: + options_by_member = urllib.parse.unquote(options_by_member) + + # BEGIN Input validation + if options_by_member is not None: + options_by_member = get_options_by_member( + options_by_member, + rv_members=rv_members, + ) + return render( request, - "rv_voting_calc/index.html" + "rv_voting_calc/index.html", + { + "rv_members": rv_members, + "keyed_rv_members": keyed_rv_members, + "options_by_member": options_by_member, + } + ) + + +def get_rv_members(request, get_rv_gid: bool = False): + transport = RequestsHTTPTransport(url=settings.CHOBOTNICE_API_URL) + client = gql.Client( + transport=transport, + fetch_schema_from_transport=True, + ) + + rv_gid = None + + if "rv_gid" in request.GET: + rv_gid = urllib.parse.unquote(request.GET["rv_gid"]) + + try: + base64.b64decode(rv_gid, validate=True) + except binascii.Error: + raise HTTPExceptions.BAD_REQUEST + else: + rv_gid = settings.CHOBOTNICE_RV_GID + + # Get members from query + query = gql.gql( + f""" + {{ + group(id: "{rv_gid}") {{ + memberships {{ + person {{ + username + displayName + officialLastName + }} + }} + }} + }} + """ + ) + + try: + result = client.execute(query) + except TransportQueryError: + # rv_gid was not found + raise HTTPExceptions.BAD_REQUEST + + rv_members = [] + + # Convert to a nicer format + for member in result["group"]["memberships"]: + rv_members.append(member["person"]) + + # Sort alphabetically + rv_members.sort(key=lambda value: value["displayName"]) + + if get_rv_gid: + return rv_members, rv_gid + else: + return rv_members + + +def convert_rv_members_to_dict(source: list) -> dict: + rv_members = {} + + for member in source: + # Convert to username: {data} + rv_members[member["username"]] = { + "displayName": member["displayName"], + "officialLastName": member["officialLastName"] + } + + return rv_members + + +def do_step_b_through_d( + request, + iteration: int, + html_steps: list, + md_steps: list, + options: dict, + options_without_support: list, + options_by_member: dict, + rv_members: dict, +) -> tuple: + ## BEGIN Step B + + options_marked_for_deletion = [] + + # Move ticket votes to the next most popular option, if necessary + for option_key, option in options.items(): + votes_to_remove = [] + + if option["marked_for_deletion"]: + options_marked_for_deletion.append(option_key) + + # If the option has support and isn't marked for deletion, we don't need + # to do anything. + if option["has_support"] and not option["marked_for_deletion"]: + continue + + # For all of the option's votes... + for vote_list_position, vote in enumerate(option["ticket_votes"]): + # Find the other acceptable options listed within... + for other_acceptable_option in vote["other_acceptable"]: + # ... if the other acceptable option no longer exists, do nothing. + if other_acceptable_option not in options: + continue + + # ... if the other acceptable option has support and isn't marked + # for deletion, move this ticket vote there. We don't need to check + # positions, since the list we are looping through is ordered + # by popularity. + if ( + options[other_acceptable_option]["has_support"] + and not options[other_acceptable_option]["marked_for_deletion"] + ): + votes_to_remove.append(vote_list_position) + options[other_acceptable_option]["ticket_votes"].append(vote) + options[other_acceptable_option]["ticket_count"] += 1 + break + + # Remove votes that have been moved from this option. + index_offset = 0 + + for vote_list_position in votes_to_remove: + option["ticket_votes"].pop(vote_list_position - index_offset) + + index_offset += 1 + + # If we are on the first iteration, show the list of options with those that + # don't have support removed. + if iteration == 0: + # Get the number of options that have support, for displaying. + options_with_support_count = 0 + + for option in options.values(): + if option["has_support"]: + options_with_support_count += 1 + + context = { + "options": options, + "options_by_member": options_by_member, + "options_with_support_count": options_with_support_count, + "rv_members": rv_members + } + + html_steps.append( + render_to_string( + "rv_voting_calc/html_steps/with_support.html", + context, + request=request, + ) + ) + md_steps.append( + render_to_string( + "rv_voting_calc/md_steps/with_support.md", + context, + request=request, + ) + ) + + # Delete options without support and those marked for deletion. + for option_key in (options_without_support + options_marked_for_deletion): + options.pop(option_key, None) + + # If no options remain, show that there are no winners and end everything. + if len(options) == 0: + html_steps.append( + render_to_string( + "rv_voting_calc/html_steps/no_winner.html", + {}, + request=request, + ) + ) + md_steps.append( + render_to_string( + "rv_voting_calc/md_steps/no_winner.md", + {}, + request=request, + ) + ) + + return options, True + + ## END Step B + + ## BEGIN Step C + + # Sort options by their vote count. + options = { + key: value + for key, value + in sorted( + options.items(), + reverse=True, + key=lambda option: option[1]["vote_count"], + ) + } + + # Find winners. + winner_vote_count = options[next(iter(options))]["vote_count"] + ticket_count_same = True + winner_count = 0 + winning_option_keys = [] + + for option_key, option in options.items(): + if option["vote_count"] == winner_vote_count: + if option["ticket_count"] != options[next(iter(options))]["ticket_count"]: + ticket_count_same = False + + winning_option_keys.append(option_key) + winner_count += 1 + + # If there is exactly 1 winner, show the winner and end everything. + if winner_count == 1: + context = { + "option_key": winning_option_keys[0], + "option": options[winning_option_keys[0]], + } + + html_steps.append( + render_to_string( + "rv_voting_calc/html_steps/found_popularity_winner.html", + context, + request=request, + ) + ) + md_steps.append( + render_to_string( + "rv_voting_calc/md_steps/found_popularity_winner.md", + context, + request=request, + ) + ) + + return options, True + # If all options are winners and have the same ticket vote count, + # randomly eliminate one and continue to the next iteration. + elif winner_count == len(options) and ticket_count_same: + eliminated_winner = random.choice(winning_option_keys) + + options[eliminated_winner]["marked_for_deletion"] = True + + context = { + "iteration": iteration, + "option_key": eliminated_winner, + "option": options[eliminated_winner], + "options": options, + "options_by_member": options_by_member, + "rv_members": rv_members, + "randomly": True + } + + html_steps.append( + render_to_string( + "rv_voting_calc/html_steps/removed_option.html", + context, + request=request, + ) + ) + md_steps.append( + render_to_string( + "rv_voting_calc/md_steps/removed_option.md", + context, + request=request, + ) + ) + + return options, False + + ## END Step C + + ## BEGIN Step D + + # Sort options by their vote count, with lowest ranking ones first. + options = { + key: value + for key, value + in sorted( + options.items(), + key=lambda option: option[1]["vote_count"], + ) + } + + # Find losing options. + loser_vote_count = options[next(iter(options))]["vote_count"] + loser_count = 0 + losing_option_keys = [] + + for option_key, option in options.items(): + if option["vote_count"] == loser_vote_count: + losing_option_keys.append(option_key) + loser_count += 1 + + # If there is exactly one losing option, delete it and move on to the next + # iteration. + if loser_count == 1: + loser_key = next(iter(options)) + + options[loser_key]["marked_for_deletion"] = True + + context = { + "iteration": iteration, + "option_key": loser_key, + "option": options[loser_key], + "options": options, + "options_by_member": options_by_member, + "rv_members": rv_members, + } + + html_steps.append( + render_to_string( + "rv_voting_calc/html_steps/removed_option.html", + context, + request=request, + ) + ) + md_steps.append( + render_to_string( + "rv_voting_calc/md_steps/removed_option.md", + context, + request=request, + ) + ) + + return options, False + else: + # If there are multiple losing options, order them by the amount of + # ticket votes they have. + losing_option_keys.sort( + key=lambda key: options[key]["ticket_count"] + ) + + # Delete the vote with the least ticket votes. There maybe more, but + # those should be taken care of in the next iteration. + options[losing_option_keys[0]]["marked_for_deletion"] = True + + context = { + "iteration": iteration, + "option_key": losing_option_keys[0], + "option": options[losing_option_keys[0]], + "options": options, + "options_by_member": options_by_member, + "rv_members": rv_members, + } + + html_steps.append( + render_to_string( + "rv_voting_calc/html_steps/removed_option.html", + context, + request=request, + ) + ) + md_steps.append( + render_to_string( + "rv_voting_calc/md_steps/removed_option.md", + context, + request=request, + ) + ) + + # End this iteration and move to the next one. + return options, False + + ## END Step D + + # (TODO: Will this function ever actually reach the code below?) + + # If there are no winners, show that and end everything. + if len(options) == 0: + html_steps.append( + render_to_string( + "rv_voting_calc/html_steps/no_winner.html", + {}, + request=request, + ) + ) + md_steps.append( + render_to_string( + "rv_voting_calc/md_steps/no_winner.md", + {}, + request=request, + ) + ) + + return options, True + + # Continue on to the next iteration. + return options, False + + +def get_calculated_votes(request): + options_by_member = request.GET.get("votes") + if options_by_member is not None: + options_by_member = urllib.parse.unquote(options_by_member) + + # BEGIN Input validation + + if options_by_member is None: + raise HTTPExceptions.BAD_REQUEST + + rv_members, rv_gid = get_rv_members(request, get_rv_gid=True) + options_by_member = get_options_by_member( + options_by_member, + rv_members=rv_members, ) + rv_members = convert_rv_members_to_dict(rv_members) + + # END Input validation + + ## BEGIN Step A + # Sorting + + html_steps = [] + md_steps = [] + + options = {} + total_ticket_count = len(options_by_member) # 1 member = 1 ticket + has_support_treshold = math.ceil(total_ticket_count * 0.5) + + # If the ticket count is divisible by 2, add 1 to make the treshold >half + if total_ticket_count % 2 == 0: + has_support_treshold += 1 + + # For each member's selected options... + for member, selected_options in options_by_member.items(): + for position, option in enumerate(selected_options): + # Add the option to the options list if it isn't there already. + if option not in options: + options[option] = { + "ticket_count": 0, + "vote_count": 0, + "marked_for_deletion": False, + "has_support": False, + "ticket_votes": [], + } + + # If this vote isn't a ticket vote, don't do anything else. + if position != 0: + continue + + # Add this vote to the option's ticket votes. + options[option]["ticket_votes"].append({ + "member": member, + "other_acceptable": selected_options[1:] + }) + # Increase the vote and ticket vote counters. + options[option]["ticket_count"] += 1 + options[option]["vote_count"] += 1 + + # For all other acceptable options the member selected... + for other_acceptable_option in selected_options[1:]: + # If the option isn't among in the options list, add it. + if other_acceptable_option not in options: + options[other_acceptable_option] = { + "ticket_count": 0, + "vote_count": 0, + "marked_for_deletion": False, + "has_support": False, + "ticket_votes": [], + } + + # Increase the option's vote counter. + options[other_acceptable_option]["vote_count"] += 1 + + # Mark options that don't meet the >half acceptability treshold as such. + options_without_support = [] + + for option_key, option in options.items(): + if option["vote_count"] >= has_support_treshold: + option["has_support"] = True + + if not option["has_support"]: + options_without_support.append(option_key) + continue + + # Order the options based on whether they have support or not. + # This is purely for aesthetic purpose. + options = { + key: value + for key, value + in sorted( + options.items(), + reverse=True, + key=lambda option: option[1]["has_support"], + ) + } + + context = { + "options": options, + "options_by_member": options_by_member, + "rv_members": rv_members, + "total_ticket_count": total_ticket_count, + "has_support_treshold": has_support_treshold + } + + html_steps.append( + render_to_string( + "rv_voting_calc/html_steps/initial_sort.html", + context, + request=request, + ) + ) + md_steps.append( + render_to_string( + "rv_voting_calc/md_steps/initial_sort.md", + context, + request=request, + ) + ) + + ## END Step A + + # Make sure we can always repeat a previously made calculation. This is also + # useful for permalinks. + + # First, try to find the seed in the URL parameters + seed = request.GET.get( + "seed", + # Then, try to find it in the cookies + request.COOKIES.get( + "seed", + # If neither are set, generate a default. + base64.b64encode(secrets.token_bytes()).decode("utf-8") + ) + ) + + # Unquote the seed if it was found in the URL parameters + if "seed" in request.GET: + seed = urllib.parse.unquote(seed) + + random.seed(seed) + + print(seed) + + # Continue until steps B - D have reached a final conclusion and count + # the amount of times the function ran to achieve this. + iteration = 0 + + while True: + options, ended = do_step_b_through_d( + request, + iteration, + html_steps, + md_steps, + options, + options_without_support, + options_by_member, + rv_members, + ) + + if ended: + response = render( + request, + "rv_voting_calc/combined_steps.html", + { + "html_steps": "\n".join(html_steps), + "md_steps": md_steps + } + ) + + # Set the random seed cookie to the one used in the calculation. + response.set_cookie("seed", seed) + # Do the same for RV's GID. + response.set_cookie("rv_gid", rv_gid) + + return response + + # Step E + iteration += 1 diff --git a/rybicka/settings/base.py b/rybicka/settings/base.py index 8eb3f074efd718e15cae089d7e2d56c583680aa8..9d59ae05d40982c7e2da515a79e225197e7ba1de 100644 --- a/rybicka/settings/base.py +++ b/rybicka/settings/base.py @@ -47,6 +47,7 @@ INSTALLED_APPS = [ "shared", "member_group_size_calc", + "rv_voting_calc", ] MIDDLEWARE = [ @@ -56,6 +57,9 @@ MIDDLEWARE = [ "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + + "django_http_exceptions.middleware.ExceptionHandlerMiddleware", + "django_http_exceptions.middleware.ThreadLocalRequestMiddleware", ] ROOT_URLCONF = "rybicka.urls" @@ -112,3 +116,11 @@ WEBPACK_LOADER = { "IGNORE": [r".+\.hot-update.js", r".+\.map"], } } + +# Chobotnice + +CHOBOTNICE_API_URL=env.str( + "CHOBOTNICE_API_URL", + "https://chobotnice.pirati.cz/graphql/" +) +CHOBOTNICE_RV_GID=env.str("CHOBOTNICE_RV_GID") diff --git a/rybicka/settings/prod.py b/rybicka/settings/production.py similarity index 100% rename from rybicka/settings/prod.py rename to rybicka/settings/production.py diff --git a/rybicka/urls.py b/rybicka/urls.py index fbfedf16485e96b0d616556486740e77b2148b92..c5add90411d59cf57d2885ad5030aaccffb7ba41 100644 --- a/rybicka/urls.py +++ b/rybicka/urls.py @@ -16,6 +16,7 @@ Including another URLconf from django.urls import include, path urlpatterns = [ - path("vypocet-skupiny-clenu", include("member_group_size_calc.urls")), + path("vypocet-skupiny-clenu/", include("member_group_size_calc.urls")), + path("hlasovani-rv/", include("rv_voting_calc.urls")), path("", include("shared.urls")), ] diff --git a/shared/static/shared/scissors.webp b/shared/static/shared/scissors.webp new file mode 100644 index 0000000000000000000000000000000000000000..d81ed816900645b471977afec959b0bb8fe33dd9 Binary files /dev/null and b/shared/static/shared/scissors.webp differ diff --git a/shared/static/shared/voting.webp b/shared/static/shared/voting.webp new file mode 100644 index 0000000000000000000000000000000000000000..faf0b02ba0e4cbbd6bf479cfc9a4c78234132c12 Binary files /dev/null and b/shared/static/shared/voting.webp differ diff --git a/shared/templates/shared/base.html b/shared/templates/shared/base.html index b89cf70c4bbbc2a574284bf79d6a34dd28c21cfe..0c612109a4a97d224b81f3b88743661302fcb733 100644 --- a/shared/templates/shared/base.html +++ b/shared/templates/shared/base.html @@ -39,6 +39,7 @@ <link rel="shortcut icon" type="image/png" href="https://www.pirati.cz/static/shared/favicon/favicon-32.png" sizes="32x32"> <link rel="shortcut icon" type="image/png" href="https://www.pirati.cz/static/shared/favicon/favicon-16.png" sizes="16x16"> + {% render_bundle "shared" %} {% render_bundle "base" %} {% block head %}{% endblock %} diff --git a/shared/templates/shared/index.html b/shared/templates/shared/index.html index a74d755713145504b354ee941cc6b5ceca9765c4..760351875888695140b3a69a4dc780248a01c464 100644 --- a/shared/templates/shared/index.html +++ b/shared/templates/shared/index.html @@ -14,8 +14,8 @@ {% block content %} <main> <h1 class="text-6xl font-bebas mb-5">Rychlé nástroje</h1> - <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> - <article class="card"> + <ul class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> + <li class="card"> <a href="{% url "member_group_size_calc:index" %}"> <img src="{% static "shared/calculator.webp" %}" @@ -33,7 +33,45 @@ Výpočet velikosti skupiny členů podle jednacího řádu. </div> </div> - </article> - </div> + </li> + <li class="card"> + <a href="{% url "rv_voting_calc:index" %}"> + <img + src="{% static "shared/voting.webp" %}" + alt="Kalkulačka velikosti skupiny členů" + class="w-full h-48 object-cover" + > + </a> + <div class="p-4"> + <h2 class="mb-2 text-xl font-bold"> + <a href="{% url "rv_voting_calc:index" %}"> + Kalkulačka hlasování RV + </a> + </h2> + <div class="font-light text-sm break-words"> + Výpočet velikosti skupiny členů podle jednacího řádu. + </div> + </div> + </li> + <li class="card"> + <a href="https://z.pirati.cz" target="_blank"> + <img + src="{% static "shared/scissors.webp" %}" + alt="Zkracovač odkazů" + class="w-full h-48 object-cover" + > + </a> + <div class="p-4"> + <h2 class="mb-2 text-xl font-bold"> + <a href="https://z.pirati.cz" target="_blank"> + Zkracovač odkazů + </a> + </h2> + <div class="font-light text-sm break-words"> + Webová aplikace sloužící k vytvoření alternativních krátkých URL adres. + </div> + </div> + </li> + </ul> </main> {% endblock %} diff --git a/static_src/member_group_size_calc.js b/static_src/member_group_size_calc.js index 85bf1402d8aafe5adeba5d3f6dccdbaa4a273ca5..be1f821d036ffaaab5e52a701c4bf17e3e3774cf 100644 --- a/static_src/member_group_size_calc.js +++ b/static_src/member_group_size_calc.js @@ -1,9 +1,5 @@ import $ from "jquery"; -import "@fortawesome/fontawesome-free/js/fontawesome"; -import "@fortawesome/fontawesome-free/js/solid"; -import "@fortawesome/fontawesome-free/js/regular"; - $(window).ready( () => { $("#member-count").on( diff --git a/static_src/rv_voting_calc.js b/static_src/rv_voting_calc.js new file mode 100644 index 0000000000000000000000000000000000000000..828ba0e3e8f8ab7202ca7e1634c103372a4a53b4 --- /dev/null +++ b/static_src/rv_voting_calc.js @@ -0,0 +1,135 @@ +import jQuery from "jquery"; + +Object.assign(window, { $: jQuery, jQuery }); + +import Cookies from "js-cookie"; + +require("select2/dist/js/i18n/cs"); +import "select2/dist/js/select2.full"; +import "select2/dist/css/select2.min.css"; + +$(window).ready( + () => { + const RV_MEMBERS = JSON.parse($("#rv-members")[0].textContent); + + $(".__vote-selection").select2({ + tags: true, + tokenSeparators: [",", " "], + // https://stackoverflow.com/a/28657702 - Thanks to Artur Filipiak! + createTag: tag => { + return { + id: tag.term, + text: tag.term, + isNew : true + }; + } + }); + + $(".__vote-selection").on( + "select2:selecting", + event => { + // Prevent empty tags. + if (event.params.args.data.id == "") { + event.preventDefault(); + } + } + ); + + $(".__vote-selection").on( + "select2:select", + event => { + $("#count-votes").prop("disabled", false); + + // Keep user-defined ordering. This is imperative! + // http://github.com/select2/select2/issues/3106 - Thanks to kevin-brown! + const element = $(event.params.data.element); + + element.detach(); + $(this).append(element); + $(this).trigger("change"); + } + ); + + $(".__vote-selection").on( + "select2:unselect", + event => { + for (const selection of $(".__vote-selection")) { + if ($(selection).select2("data").length !== 0) { + return; + } + } + + $("#count-votes").prop("disabled", true); + } + ); + + $("#count-votes").on( + "click", + async (event) => { + $(event.currentTarget).addClass("btn--loading").prop("disabled", true); + + let votes = {}; + + for (const username of Object.keys(RV_MEMBERS)) { + const selectedOptions = $(`#${$.escapeSelector(username)}-selection`).select2("data"); + + if (selectedOptions.length === 0) { + continue; + } + + votes[username] = []; + + for (const option of selectedOptions) { + votes[username].push(option.id); + } + } + + const urlParams = new URL(window.location).searchParams; + const rvGid = urlParams.get("rv_gid"); + const seed = urlParams.get("seed"); + + let response = await fetch( + VOTE_CALCULATION_ENDPOINT + + `?votes=${encodeURIComponent(JSON.stringify(votes))}` + + ( + (rvGid !== null) ? + `&rv_gid=${encodeURIComponent(rvGid)}` : "" + ) + + ( + (seed !== null) ? + `&seed=${encodeURIComponent(seed)}` : "" + ) + ); + + if (!response.ok) { + alert("Chyba při získávání výsledků hlasování."); + return; + } + + $("#result").html(await response.text()); + + $("#permalink,#download-log").show(); + $("#permalink").attr( + "href", + ( + // Base URL + window.location.href.split('?')[0] + + `?votes=${encodeURIComponent(JSON.stringify(votes))}` + + `&rv_gid=${encodeURIComponent(Cookies.get("rv_gid"))}` + + `&seed=${encodeURIComponent(Cookies.get("seed"))}` + ) + ); + $("#download-log").attr( + "href", + ( + "data:text/plain;charset=utf-8," + + encodeURIComponent($("#md-steps").html()) + ) + ); + + $("#result")[0].scrollIntoView(); + $(event.currentTarget).removeClass("btn--loading").prop("disabled", false); + } + ); + } +) diff --git a/static_src/shared/utils.js b/static_src/shared/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..7a64d68dfe128aaa3d02173ce815da76ac4baf8d --- /dev/null +++ b/static_src/shared/utils.js @@ -0,0 +1,5 @@ +const escapeHTML = (source) => { + return new Option(source).innerHTML; +} + +export { escapeHTML }; diff --git a/tailwind.config.js b/tailwind.config.js index 6d9541ca09b55c509684300c618cf6c106419607..882ff8fc6f21218aac61604077d8e0cdd47ff91e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,4 +1,4 @@ -const defaultTheme = require('tailwindcss/defaultTheme') +const defaultTheme = require("tailwindcss/defaultTheme"); /** @type {import('tailwindcss').Config} */ module.exports = { diff --git a/webpack.config.js b/webpack.config.js index bb38e048355512ab66e1552d8d8e44d2ab37c14e..d187e3f50a23b30b2bf9fc4e0ed3ff609aea096f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,8 +5,19 @@ module.exports = { mode: "production", context: __dirname, entry: { - base: path.resolve("static_src", "base.js"), - member_group_size_calc: path.resolve("static_src", "member_group_size_calc.js"), + base: { + import: path.resolve("static_src", "base.js"), + dependOn: "shared", + }, + member_group_size_calc: { + import: path.resolve("static_src", "member_group_size_calc.js"), + dependOn: "shared", + }, + rv_voting_calc: { + import: path.resolve("static_src", "rv_voting_calc.js"), + dependOn: "shared", + }, + shared: ["jquery"], }, output: { path: path.resolve(__dirname, "shared", "static", "shared"), @@ -20,6 +31,9 @@ module.exports = { }, ], }, + optimization: { + runtimeChunk: "single", + }, plugins: [ new BundleTracker({filename: './webpack-stats.json'}) ],