From 5931b7c5cdb682992197dad8620405856f706ae6 Mon Sep 17 00:00:00 2001 From: Alexis POYEN <apoyen@grandlyon.com> Date: Thu, 30 Jul 2020 14:17:32 +0200 Subject: [PATCH] Resolve "Select maps for elections" --- README.md | 4 ++ docker-compose.yml | 8 ++- internal/models/election.go | 2 + internal/models/models.go | 35 +++++++--- internal/rootmux/admin_test.go | 8 +-- internal/rootmux/capturer_test.go | 8 +-- internal/rootmux/rootmux_test.go | 2 +- internal/rootmux/visualizer_test.go | 8 +-- web/components/management/election.js | 77 +++++++++++++++++++-- web/components/management/round-desks.js | 2 +- web/components/visualization/results-map.js | 17 ++++- web/components/vote/vote-page.js | 2 +- web/services/model/election-model.js | 19 +++-- web/style.css | 2 +- 14 files changed, 156 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 5823fdf..e904069 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ La démo est accessible avec l'url https://elections.127.0.0.1.nip.io - `./miscellaneous/keycloack` contient un environnement Keycloack qui peut être utilisé pour déployer un environnement OAuth2 - `./web` est le répertoire où est stocké l'application front-end en JavaScript natif et avec le framework CSS Bulma publié par le serveur back-end. +Le répertoire `./web/assets/maps` contient les cartes qui peuvent être utilisé dans l'application. Pour en ajouter de nouvelles en production copier les fichiers désirés dans ce répertoire. + +Avec docker-compose : docker cp <fichier-à -importer> <nom-du-conteneur>:/app/web/assets/maps/<fichier-à -importer> + ### Utilisateurs et droits **Utilisateurs techniques** Les utilisateurs techniques permettent de s'authentifier à l'application et d'accéder aux API en fonction du rôle de l'utilisateur qui définit alors ses droits. diff --git a/docker-compose.yml b/docker-compose.yml index 15fb484..c2be700 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,19 +4,20 @@ services: elections-container: image: elections build: . - #command: -debug + command: -debug restart: unless-stopped volumes: - /etc/localtime:/etc/localtime:ro - ./configs:/app/configs - ./letsencrypt_cache:/app/letsencrypt_cache - ./data:/app/data + - maps-volume:/app/web/assets/maps ports: - 443:443 - 80:80 environment: - HOSTNAME=${HOSTNAME} - - ADMIN_ROLE=${ADMIN_ROLE} + - ADMIN_ROLE=${ADMIN_GROUP} - REDIRECT_URL=${REDIRECT_URL} - CLIENT_ID=${CLIENT_ID} - CLIENT_SECRET=${CLIENT_SECRET} @@ -24,3 +25,6 @@ services: - TOKEN_URL=${TOKEN_URL} - USERINFO_URL=${USERINFO_URL} - LOGOUT_URL=${LOGOUT_URL} + +volumes: + maps-volume: diff --git a/internal/models/election.go b/internal/models/election.go index 01f72a3..dfc9a5a 100644 --- a/internal/models/election.go +++ b/internal/models/election.go @@ -104,6 +104,8 @@ func (d *DataHandler) putElection(w http.ResponseWriter, r *http.Request, id int } o.Name = election.Name o.BallotType = election.BallotType + o.MapAreaFile = election.MapAreaFile + o.MapSectionFile = election.MapSectionFile d.db.Save(&o) json.NewEncoder(w).Encode(o) diff --git a/internal/models/models.go b/internal/models/models.go index 5ae35b1..f389279 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -1,7 +1,10 @@ package models import ( + "encoding/json" + "fmt" "net/http" + "path/filepath" "strings" "time" @@ -46,14 +49,16 @@ const ErrorValidatedVote = "Error the vote have already been validated and can't // Election represent an election divided in areas with 1 or several rounds type Election struct { - ID uint `gorm:"primary_key"` - CreatedAt time.Time `json:"-"` - UpdatedAt time.Time `json:"-"` - DeletedAt *time.Time `json:"-"` - Name string - BallotType string - Areas []Area - Rounds []Round + ID uint `gorm:"primary_key"` + CreatedAt time.Time `json:"-"` + UpdatedAt time.Time `json:"-"` + DeletedAt *time.Time `json:"-"` + Name string + BallotType string + MapAreaFile string + MapSectionFile string + Areas []Area + Rounds []Round } // Area represent an area of an election divided in one or several Sections @@ -251,6 +256,8 @@ func (d *DataHandler) ProcessAPI(w http.ResponseWriter, r *http.Request) { d.handleCandidate(w, r) case "Vote": d.handleVote(w, r) + case "Maps": + d.handleMaps(w, r) } } @@ -270,3 +277,15 @@ func (d *DataHandler) getLoggedUser(w http.ResponseWriter, r *http.Request) inte return nil } + +func (d *DataHandler) handleMaps(w http.ResponseWriter, r *http.Request) { + switch method := r.Method; method { + case "GET": + matches, _ := filepath.Glob("web/assets/maps/*") + fmt.Println(matches) + json.NewEncoder(w).Encode(matches) + default: + http.Error(w, "method not allowed", 400) + } + +} diff --git a/internal/rootmux/admin_test.go b/internal/rootmux/admin_test.go index 76c5914..9e2ae85 100644 --- a/internal/rootmux/admin_test.go +++ b/internal/rootmux/admin_test.go @@ -34,13 +34,13 @@ func AdminTests(t *testing.T) { do("DELETE", "/api/Capturer/3", xsrfHeader, ``, 200, ``) // Create an Election - do("POST", "/api/Election", xsrfHeader, `{"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct"}`, 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","Areas":null,"Rounds":null}`) + do("POST", "/api/Election", xsrfHeader, `{"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json"}`, 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json","Areas":null,"Rounds":null}`) // Get the election - do("GET", "/api/Election/1", xsrfHeader, ``, 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","Areas":[],"Rounds":null}`) + do("GET", "/api/Election/1", xsrfHeader, ``, 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json","Areas":[],"Rounds":null}`) // Get all the elections - do("GET", "/api/Election/", xsrfHeader, ``, 200, `[{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","Areas":[],"Rounds":null}]`) + do("GET", "/api/Election/", xsrfHeader, ``, 200, `[{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json","Areas":[],"Rounds":null}]`) // Update an election - do("PUT", "/api/Election/1", xsrfHeader, `{"ID":1,"Name":"Grand-Lyon 2020", "BallotType":"metropolitan-direct"}`, 200, `{"ID":1,"Name":"Grand-Lyon 2020","BallotType":"metropolitan-direct","Areas":[],"Rounds":null}`) + do("PUT", "/api/Election/1", xsrfHeader, `{"ID":1,"Name":"Grand-Lyon 2020", "BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json"}`, 200, `{"ID":1,"Name":"Grand-Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json","Areas":[],"Rounds":null}`) // Create an Area do("POST", "/api/Area", xsrfHeader, `{"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1"}`, 200, `{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}`) diff --git a/internal/rootmux/capturer_test.go b/internal/rootmux/capturer_test.go index 7a13671..335d887 100644 --- a/internal/rootmux/capturer_test.go +++ b/internal/rootmux/capturer_test.go @@ -36,13 +36,13 @@ func CapturerTests(t *testing.T) { do("DELETE", "/api/Capturer/1", xsrfHeader, ``, 405, `You're not authorize to execute this method on this ressource.`) // Create an election should fail with 405 - do("POST", "/api/Election", xsrfHeader, `{"Name":"Grand Lyon 2020", "BallotType":"metropolitan-direct"}`, 405, `You're not authorize to execute this method on this ressource.`) + do("POST", "/api/Election", xsrfHeader, `{"Name":"Grand Lyon 2020", "BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json"}`, 405, `You're not authorize to execute this method on this ressource.`) // Get an Election - do("GET", "/api/Election/1", xsrfHeader, "", 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","Areas":[{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}],"Rounds":null}`) + do("GET", "/api/Election/1", xsrfHeader, "", 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json","Areas":[{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}],"Rounds":null}`) // Get all the elections - do("GET", "/api/Election/", xsrfHeader, "", 200, `[{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","Areas":[{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}],"Rounds":null}]`) + do("GET", "/api/Election/", xsrfHeader, "", 200, `[{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json","Areas":[{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}],"Rounds":null}]`) // Update an election should fail with 405 - do("PUT", "/api/Election/1", xsrfHeader, `{"Name":"Grand Lyon 2020", "BallotType":"metropolitan-direct"}`, 405, `You're not authorize to execute this method on this ressource.`) + do("PUT", "/api/Election/1", xsrfHeader, `{"Name":"Grand Lyon 2020", "BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json"}`, 405, `You're not authorize to execute this method on this ressource.`) // Delete an election should fail with 405 do("DELETE", "/api/Election/1", xsrfHeader, ``, 405, `You're not authorize to execute this method on this ressource.`) diff --git a/internal/rootmux/rootmux_test.go b/internal/rootmux/rootmux_test.go index e1b47c0..19e36dd 100644 --- a/internal/rootmux/rootmux_test.go +++ b/internal/rootmux/rootmux_test.go @@ -292,7 +292,7 @@ func resetDataWithData(t *testing.T) { do("POST", "/api/Capturer", xsrfHeader, `{"UserID":2,"Name":"Capturer"}`, 200, `{"ID":1,"UserID":2,"Name":"Capturer","DeskRounds":null}`) do("POST", "/api/Capturer", xsrfHeader, `{"UserID":3,"Name":"Capturer"}`, 200, `{"ID":2,"UserID":3,"Name":"Capturer","DeskRounds":null}`) - do("POST", "/api/Election", xsrfHeader, `{"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct"}`, 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","Areas":null,"Rounds":null}`) + do("POST", "/api/Election", xsrfHeader, `{"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json"}`, 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json","Areas":null,"Rounds":null}`) do("POST", "/api/Area", xsrfHeader, `{"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1"}`, 200, `{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}`) do("POST", "/api/Section", xsrfHeader, `{"AreaID":1,"Name":"Section 1","MapID":"1"}`, 200, `{"ID":1,"AreaID":1,"Name":"Section 1","MapID":"1","Desks":null}`) do("POST", "/api/Desk", xsrfHeader, `{"SectionID":1,"Name":"Desk 1","WitnessDesk":true,"Subscribed":9587}`, 200, `{"ID":1,"SectionID":1,"Name":"Desk 1","WitnessDesk":true,"Subscribed":9587,"DeskRounds":null}`) diff --git a/internal/rootmux/visualizer_test.go b/internal/rootmux/visualizer_test.go index 48ab80d..77c1c57 100644 --- a/internal/rootmux/visualizer_test.go +++ b/internal/rootmux/visualizer_test.go @@ -34,13 +34,13 @@ func VisualizerTests(t *testing.T) { do("DELETE", "/api/Capturer/1", xsrfHeader, ``, 405, `You're not authorize to execute this method on this ressource.`) // Create an election should fail with 405 - do("POST", "/api/Election", xsrfHeader, `{"Name":"Grand Lyon 2020", "BallotType":"metropolitan-direct"}`, 405, `You're not authorize to execute this method on this ressource.`) + do("POST", "/api/Election", xsrfHeader, `{"Name":"Grand Lyon 2020", "BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json"}`, 405, `You're not authorize to execute this method on this ressource.`) // Get an Election - do("GET", "/api/Election/1", xsrfHeader, "", 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","Areas":[{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}],"Rounds":null}`) + do("GET", "/api/Election/1", xsrfHeader, "", 200, `{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json","Areas":[{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}],"Rounds":null}`) // Get all the elections - do("GET", "/api/Election/", xsrfHeader, "", 200, `[{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","Areas":[{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}],"Rounds":null}]`) + do("GET", "/api/Election/", xsrfHeader, "", 200, `[{"ID":1,"Name":"Grand Lyon 2020","BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json","Areas":[{"ID":1,"ElectionID":1,"Name":"Area 1","SeatNumber":9,"MapID":"1","Sections":null}],"Rounds":null}]`) // Update an election should fail with 405 - do("PUT", "/api/Election/1", xsrfHeader, `{"Name":"Grand Lyon 2020", "BallotType":"metropolitan-direct"}`, 405, `You're not authorize to execute this method on this ressource.`) + do("PUT", "/api/Election/1", xsrfHeader, `{"Name":"Grand Lyon 2020", "BallotType":"metropolitan-direct","MapAreaFile":"web/assets/maps/area.json","MapSectionFile":"web/assets/maps/section.json"}`, 405, `You're not authorize to execute this method on this ressource.`) // Delete an election should fail with 405 do("DELETE", "/api/Election/1", xsrfHeader, ``, 405, `You're not authorize to execute this method on this ressource.`) diff --git a/web/components/management/election.js b/web/components/management/election.js index c316b22..773f8df 100644 --- a/web/components/management/election.js +++ b/web/components/management/election.js @@ -121,6 +121,24 @@ class Election { </select> </div> </div> + <div class="field"> + <label>Carte de circonscriptions</label><br /> + <div class="control select"> + <select + name="map-area-selector" + id="election-modal-area-map" + ></select> + </div> + </div> + <div class="field"> + <label>Carte de villes</label><br /> + <div class="control select"> + <select + name="map-section-selector" + id="election-modal-section-map" + ></select> + </div> + </div> </section> <footer class="modal-card-foot"> <button id="election-modal-save" class="button is-success"> @@ -302,16 +320,18 @@ class Election { .classList.add("active-card"); } - newElection() { + async newElection() { this.method = "POST"; + await this.refreshMapSelect(); document.getElementById("election-modal-id").value = null; document.getElementById("election-modal-name").value = null; document.getElementById("election-modal-ballot-type").value = null; Common.toggleModal("election-modal", "election-modal-card"); } - editElection(election) { + async editElection(election) { this.method = "PUT"; + await this.refreshMapSelect(); document.getElementById("election-modal-id").value = election.ID; document.getElementById("election-modal-name").value = election.Name; document.getElementById("election-modal-ballot-type").value = @@ -328,7 +348,9 @@ class Election { this.method, parseInt(document.getElementById("election-modal-id").value), document.getElementById("election-modal-name").value, - document.getElementById("election-modal-ballot-type").value + document.getElementById("election-modal-ballot-type").value, + document.getElementById("election-modal-area-map").value, + document.getElementById("election-modal-section-map").value ); await this.displayElections(); @@ -346,7 +368,6 @@ class Election { this.parent.sectionHandler.emptySections(); this.parent.deskHandler.emptyDesks(); document.getElementById("areas").style.display = "none"; - } cloneElection(election) { @@ -378,4 +399,52 @@ class Election { this.parent.areaHandler.election = electionCloned; await this.parent.areaHandler.displayAreas(); } + + async refreshMapSelect() { + let selectMapAreas = document.getElementById("election-modal-area-map"); + let selectMapSections = document.getElementById( + "election-modal-section-map" + ); + let maps; + try { + const response = await fetch("/api/Maps/", { + method: "GET", + headers: new Headers({ + "XSRF-Token": this.ElectionModel.current_user.xsrftoken, + }), + }); + if (response.status !== 200) { + throw new Error( + `Maps could not be fetched (status ${response.status})` + ); + } + maps = await response.json(); + } catch (e) { + Messages.Show("is-warning", e.message); + console.error(e); + } + + this.addMapsInSelect(maps, selectMapAreas); + this.addMapsInSelect(maps, selectMapSections); + } + + addMapsInSelect(maps, select) { + for (let i = select.options.length - 1; i >= 0; i--) { + select.remove(i); + } + + let el = document.createElement("option"); + el.textContent = "Veuillez sélectionner une carte"; + el.value = 0; + select.appendChild(el); + maps.forEach((map) => { + el = document.createElement("option"); + el.textContent = map.substring( + map.lastIndexOf("/") + 1, + map.lastIndexOf(".") + ); + el.value = map; + select.appendChild(el); + }); + } } diff --git a/web/components/management/round-desks.js b/web/components/management/round-desks.js index 5780b8a..d9ccedc 100644 --- a/web/components/management/round-desks.js +++ b/web/components/management/round-desks.js @@ -48,7 +48,7 @@ class RoundDesk { <div id="desk-round-details"></div> </div> <div class="column is-half"> - <div id="vote-section" class="card"></div> + <div id="vote-section" class="card-no-hover"></div> </div> </div> `; diff --git a/web/components/visualization/results-map.js b/web/components/visualization/results-map.js index 28e7276..3fdb314 100644 --- a/web/components/visualization/results-map.js +++ b/web/components/visualization/results-map.js @@ -1,6 +1,7 @@ // Imports import * as PartyModel from "/services/model/party-model.js"; import * as AreaModel from "/services/model/area-model.js"; +import * as ElectionModel from "/services/model/election-model.js"; export async function mount(parent) { const mapComponent = new MapComponent(parent); @@ -12,14 +13,24 @@ class MapComponent { this.parent = parent; this.PartyModel = PartyModel.getPartyModel(); this.AreaModel = AreaModel.getAreaModel(); + this.ElectionModel = ElectionModel.getElectionModel(); } async displayMapAreas() { - await this.initMap("/assets/maps/area.json", this.colorAreas); + let election = await this.ElectionModel.getElection( + this.parent.parent.round.ElectionID + ); + await this.initMap( + election.MapAreaFile.replace("web/", ""), + this.colorAreas + ); } async displayMapSections() { - await this.initMap("/assets/maps/section.json", this.colorSections); + let election = await this.ElectionModel.getElection( + this.parent.parent.round.ElectionID + ); + await this.initMap(election.MapSectionFile.replace("web/", ""), this.colorSections); } async initMap(mapFile, colorationFunction) { @@ -42,7 +53,7 @@ class MapComponent { container: "map-component", // container id style: "/assets/mapbox/vector.json", // stylesheet location center: [4.9, 45.75], // starting position [lng, lat] - zoom: 9.7, // starting zoom + zoom: 9.5, // starting zoom }); this.map.on("load", () => { diff --git a/web/components/vote/vote-page.js b/web/components/vote/vote-page.js index 49424f9..be780ab 100644 --- a/web/components/vote/vote-page.js +++ b/web/components/vote/vote-page.js @@ -16,7 +16,7 @@ class VotePage { const mountpoint = where; document.getElementById(mountpoint).innerHTML = /* HTML */ ` <section style="margin-bottom: 230px;"> - <div class="container"><div id="vote-section" class="card"></div></div> + <div class="container"><div id="vote-section" class="card-no-hover"></div></div> </section> `; this.deskRoundHandler = await DeskRound.mount("vote-section", this); diff --git a/web/services/model/election-model.js b/web/services/model/election-model.js index a69a6c2..a00127f 100644 --- a/web/services/model/election-model.js +++ b/web/services/model/election-model.js @@ -1,12 +1,12 @@ import * as Messages from "/services/messages/messages.js"; -let electionModel +let electionModel; -export function getElectionModel(){ - if(electionModel == null) { +export function getElectionModel() { + if (electionModel == null) { electionModel = new ElectionModel(); } - return electionModel + return electionModel; } class ElectionModel { @@ -44,7 +44,14 @@ class ElectionModel { return this.elections; } - async saveElection(method, ID, Name, BallotType) { + async saveElection( + method, + ID, + Name, + BallotType, + MapAreaFile, + MapSectionFile + ) { try { const response = await fetch("/api/Election/" + ID, { method: method, @@ -55,6 +62,8 @@ class ElectionModel { ID: ID, Name: Name, BallotType: BallotType, + MapAreaFile: MapAreaFile, + MapSectionFile: MapSectionFile, }), }); if (response.status == 409) { diff --git a/web/style.css b/web/style.css index 449b497..90eb4eb 100644 --- a/web/style.css +++ b/web/style.css @@ -214,7 +214,7 @@ select { } #map-section { - height: 70vh; + height: 65vh; } #map-component { -- GitLab