diff --git a/rv_voting_calc/templates/rv_voting_calc/index.html b/rv_voting_calc/templates/rv_voting_calc/index.html index 7858b29113a067eb2adcd8e47386c77000ff8fc7..919785c438075676bf560c5e24961ec3d0e4fdae 100644 --- a/rv_voting_calc/templates/rv_voting_calc/index.html +++ b/rv_voting_calc/templates/rv_voting_calc/index.html @@ -43,13 +43,15 @@ {% endfor %} </ul> - <div> + <div id="result"> </div> </div> </main> + {{ keyed_rv_members|json_script:"rv-members" }} + <script> - const RV_MEMBERS = {{ json_rv_members|safe }}; + const VOTE_CALCULATION_ENDPOINT = "{% url "rv_voting_calc:get_calculated_votes" %}" </script> {% endblock %} diff --git a/rv_voting_calc/templates/rv_voting_calc/steps/initial_sort.html b/rv_voting_calc/templates/rv_voting_calc/steps/initial_sort.html new file mode 100644 index 0000000000000000000000000000000000000000..44de8e4893ef7a817d6e60c4238cae76e3921f21 --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/steps/initial_sort.html @@ -0,0 +1,3 @@ +CIRNO CHIRUMIRU + +{{ options }} diff --git a/rv_voting_calc/templates/rv_voting_calc/steps/with_support.html b/rv_voting_calc/templates/rv_voting_calc/steps/with_support.html new file mode 100644 index 0000000000000000000000000000000000000000..44de8e4893ef7a817d6e60c4238cae76e3921f21 --- /dev/null +++ b/rv_voting_calc/templates/rv_voting_calc/steps/with_support.html @@ -0,0 +1,3 @@ +CIRNO CHIRUMIRU + +{{ options }} 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 4bd47b308983d16825e38631357810a7425f231c..210acb746c162e90c4c636fba021754f3eac5e9b 100644 --- a/rv_voting_calc/views.py +++ b/rv_voting_calc/views.py @@ -1,5 +1,5 @@ import json -import typing +import math import gql import requests @@ -7,9 +7,9 @@ import requests from gql.transport.requests import RequestsHTTPTransport from django.conf import settings -from django.http import HttpResponseBadRequest +from django.http import HttpResponse, HttpResponseBadRequest, JsonResponse from django.shortcuts import render -from django.views.decorators.http import require_POST +from django.template.loader import render_to_string def get_rv_members() -> list: @@ -64,45 +64,200 @@ def convert_rv_members_to_dict(source: list) -> dict: def index(request): rv_members = get_rv_members() - json_rv_members = json.dumps(convert_rv_members_to_dict(rv_members)) + keyed_rv_members = convert_rv_members_to_dict(rv_members) return render( request, "rv_voting_calc/index.html", { "rv_members": rv_members, - # JS-Readable format - "json_rv_members": json.dumps(json_rv_members), + "keyed_rv_members": keyed_rv_members, } ) -@require_POST def get_calculated_votes(request): - votes = request.GET.get("votes") + options_by_member = request.GET.get("votes") - if votes is None: + # BEGIN Input validation + + if options_by_member is None: return HttpResponseBadRequest("Nebyly zadány žádné hlasy") try: - votes = json.loads(votes) + options_by_member = json.loads(options_by_member) - if not isinstance(votes, dict): + if not isinstance(options_by_member, dict): raise ValueError except ValueError: return HttpResponseBadRequest("Formát hlasů není validní") rv_members = convert_rv_members_to_dict(get_rv_members()) - rv_member_names_to_clear = rv_members.keys() - integrity_check_failed = False - for key, value in votes.items(): - if key not in rv_member_names_to_clear: + for member, options in options_by_member.items(): + # `member` is not in the list of known members + if member not in options_by_member: + integrity_check_failed = True + break + + # `options` is a list + if not isinstance(options, list): integrity_check_failed = True break - if not isinstance( + # all items in `options` are strings + for item in options: + if not isinstance(item, str): + integrity_check_failed = True + break + + if integrity_check_failed: + return HttpResponseBadRequest("Formát hlasů není validní") + + # END Input validation + + # BEGIN Sorting + + steps = [] + + options = {} + total_ticket_count = len(options_by_member) # 1 member = 1 ticket + max_vote_position = 0 + + for member, selected_options in options_by_member.items(): + option_is_ticket = True + + for position, option in enumerate(selected_options): + if position > max_vote_position: + max_vote_position = position + + if option not in options: + options[option] = { + "ticket_count": 0, + "vote_count": 0, + "points": 0, + "votes": [], + "has_support": False + } + + options[option]["votes"].append({ + "position": position, + "member": member + }) + + if option_is_ticket: + options[option]["ticket_count"] += 1 + else: + options[option]["vote_count"] += 1 + + option_is_ticket = False + + steps.append( + render_to_string( + "rv_voting_calc/steps/initial_sort.html", + { + "options": options, + "options_by_member": options_by_member, + "rv_members": rv_members + }, + request=request, + ) + ) + + # END Sorting + + # BEGIN Filtering out options without enough support and figuring out + # the most popular options + + options_to_remove = [] + + for option_key, option in options.items(): + if ( + (option["vote_count"] + option["ticket_count"]) + >= math.ceil(total_ticket_count * 0.5) + ): + option["has_support"] = True + + if not option["has_support"]: + options_to_remove.append(option_key) + continue + + option["points"] += option["ticket_count"] * (max_vote_position + 1) + + for vote in option["votes"]: + option["points"] += ( + max_vote_position + - vote["position"] + + 1 + ) + + for option_key in options_to_remove: + options.pop(option_key) + + options = { + key: value + for key, value + in sorted(options.items(), key=lambda option: option[1]["points"]) + } + + steps.append( + render_to_string( + "rv_voting_calc/steps/with_support.html", + { + "options": options, + "options_by_member": options_by_member, + "rv_members": rv_members + }, + request=request, + ) + ) + + # END Filtering out options without enough support and figuring out + # the most popular options + + return HttpResponse("\n".join(steps)) + + #acceptable_options = [] + #voting_member_count = len(options_by_member) + + #for option, members in members_by_option.items(): + #if len(members) >= math.floor(voting_member_count * 0.5): + #acceptable_options.append(option) + + #steps = [] + + #if len(acceptable_options) == 0: + #result = { + #"type": "final:no_acceptable_options", + #"voting_member_count": voting_member_count, + #"options": [], + #} + + #for option, members in members_by_option.items(): + #result["options"].append({ + #"name": option, + #"vote_count": len(members), + #}) + + #steps.append(result) + + #return JsonResponse(steps, safe=False) + + #acceptable_options_step = { + #"type": "progress:acceptable_options", + #"voting_member_count": voting_member_count, + #"options": [] + #} + + #for option, members in members_by_option.items(): + #if option in acceptable_options: + #acceptable_options_step["options"].append({ + #"name": option, + #"vote_count": len(members), + #}) + + #steps.append(acceptable_options_step) - rv_member_names_to_clear.pop(key) + # END Acceptable options diff --git a/rybicka/urls.py b/rybicka/urls.py index d2110e6239e7777744927a270052f2f4afd9b2b5..c5add90411d59cf57d2885ad5030aaccffb7ba41 100644 --- a/rybicka/urls.py +++ b/rybicka/urls.py @@ -16,7 +16,7 @@ Including another URLconf from django.urls import include, path urlpatterns = [ - path("vypocet-skupiny-clenu", include("member_group_size_calc.urls")), - path("hlasovani-rv", include("rv_voting_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/static_src/rv_voting_calc.js b/static_src/rv_voting_calc.js index 7d21f59c7528e60a2ab8ece22f1396e47caa5c5a..881051eff0c78afa54200be6e2f0bc0ef630bbe3 100644 --- a/static_src/rv_voting_calc.js +++ b/static_src/rv_voting_calc.js @@ -8,6 +8,8 @@ import "select2/dist/css/select2.min.css"; $(window).ready( () => { + const RV_MEMBERS = JSON.parse($("#rv-members")[0].textContent); + $(".__vote-selection").select2({ tags: true, tokenSeparators: [",", " "], @@ -34,39 +36,47 @@ $(window).ready( $(".__vote-selection").on( "select2:select", event => { - //// Sync the tag option with other selectors. - - // If the tag isn't new for this selection do nothing. - if (!event.params.data.isNew) { - return; - } - - const tagName = event.params.data.id; - - const addedTag = new Option( - tagName, - tagName, - false, - false - ); - - // Get all other selections. - const unfilteredSelections = $(".__vote-selection").not(event.target); - let filteredSelections = []; - - // Check if they contain the tag. If they do, ignore them. - for (const selection of unfilteredSelections) { - if ( - $(selection). - children(`option[value=${$.escapeSelector(tagName)}]`). - length === 0 - ) { - filteredSelections.push(selection); - } - } - - // Add the new tag to all selections that don't have it yet. - $(filteredSelections).append(addedTag).trigger("change"); +// //// Sync the tag option with other selectors. +// +// // If the tag isn't new for this selection, do nothing. +// if (!event.params.data.isNew) { +// return; +// } +// +// const tagName = event.params.data.id; +// +// const addedTag = new Option( +// tagName, +// tagName, +// false, +// false +// ); +// +// // Get all other selections. +// const unfilteredSelections = $(".__vote-selection").not(event.target); +// let filteredSelections = []; +// +// // Check if they contain the tag. If they do, ignore them. +// for (const selection of unfilteredSelections) { +// if ( +// $(selection). +// children(`option[value=${$.escapeSelector(tagName)}]`). +// length === 0 +// ) { +// filteredSelections.push(selection); +// } +// } +// +// // Add the new tag to all selections that don't have it yet. +// $(filteredSelections).append(addedTag).trigger("change"); + + // 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"); } ); @@ -75,50 +85,65 @@ $(window).ready( event => { //// Remove the tag option if it's not selected anywhere. - const tagName = event.params.data.id; - - // Get all other selections. - const selections = $(".__vote-selection"); - - // Check if any of them have the tag selected. If they do, end the function. - for (const selection of selections) { - for (const data of $(selection).select2("data")) { - if (data.id == tagName) { - // TODO - Don't remove the tag from this select. - // I'm not sure select2 has a good way of doing this. - - return; - } - } - } - - // If the function has not ended by now, we can remove the tag. - for (const selection of selections) { - ( - $(selection). - children(`option[value=${$.escapeSelector(tagName)}]`). - remove() - ); - - $(selection).trigger("change"); - } +// const tagName = event.params.data.id; +// +// // Get all other selections. +// const selections = $(".__vote-selection"); +// +// // Check if any of them have the tag selected. If they do, end the function. +// for (const selection of selections) { +// for (const data of $(selection).select2("data")) { +// if (data.id == tagName) { +// // TODO - Don't remove the tag from this select. +// // I'm not sure select2 has a good way of doing this. +// +// return; +// } +// } +// } +// +// // If the function has not ended by now, we can remove the tag. +// for (const selection of selections) { +// ( +// $(selection). +// children(`option[value=${$.escapeSelector(tagName)}]`). +// remove() +// ); +// +// $(selection).trigger("change"); +// } } ); $("#count-votes").on( "click", - event => { + async (event) => { 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 data of $(`#${$.escapeSelector(username)}-selection`).select2("data")) { - votes[username].push(data.id); + for (const option of selectedOptions) { + votes[username].push(option.id); } } - console.log(votes); + let response = await fetch( + `${VOTE_CALCULATION_ENDPOINT}?votes=${encodeURIComponent(JSON.stringify(votes))}` + ); + + if (!response.ok) { + alert("Chyba při získávání výsledků hlasování."); + return; + } + + $("#result").html(await response.text()); } ); } 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 = {