diff --git a/onegov/election_day/collections/__init__.py b/onegov/election_day/collections/__init__.py index b8857000..1e656a94 100644 --- a/onegov/election_day/collections/__init__.py +++ b/onegov/election_day/collections/__init__.py @@ -4,7 +4,7 @@ from onegov.election_day.collections.notifications import \ NotificationCollection from onegov.election_day.collections.archived_results import \ - ArchivedResultCollection + ArchivedResultCollection, SearchableArchivedResultCollection from onegov.election_day.collections.subscribers import \ EmailSubscriberCollection from onegov.election_day.collections.subscribers import \ @@ -14,6 +14,7 @@ __all__ = [ + 'SearchableArchivedResultCollection', 'ArchivedResultCollection', 'DataSourceCollection', 'DataSourceItemCollection', diff --git a/onegov/election_day/collections/archived_results.py b/onegov/election_day/collections/archived_results.py index 509bf212..c5cd640d 100644 --- a/onegov/election_day/collections/archived_results.py +++ b/onegov/election_day/collections/archived_results.py @@ -14,8 +14,11 @@ from sqlalchemy import extract from sqlalchemy import func from sqlalchemy import Integer +from sqlalchemy import or_ +from sqlalchemy.sql.expression import case from time import mktime from time import strptime +from onegov.core.collection import Pagination def groupbydict(items, keyfunc, sortfunc=None): @@ -30,12 +33,12 @@ def groupbydict(items, keyfunc, sortfunc=None): class ArchivedResultCollection(object): - def __init__(self, session, date=None): + def __init__(self, session, date_=None): self.session = session - self.date = date + self.date = date_ - def for_date(self, date): - return self.__class__(self.session, date) + def for_date(self, date_): + return self.__class__(self.session, date_) def query(self): return self.session.query(ArchivedResult) @@ -259,3 +262,227 @@ def delete(self, item, request): self.session.delete(item) self.session.flush() + + +class SearchableArchivedResultCollection( + ArchivedResultCollection, Pagination): + + def __init__( + self, + session, + date_=None, + from_date=None, + to_date=None, + types=None, + item_type=None, + domain=None, + term=None, + answer=None, + locale='de_CH', + page=0 + ): + """ + + :param session: + :param date_: see parent class + :param from_date: datetime.date + :param to_date: datetime.date + :param types: types as list, to search all the types + neglected if item_type is set + :param item_type: type from url path (used in tab_menu) + :param domain: list of domains + :param term: query string from form + :param answer: list of answers + :param locale: locale from request.locale + """ + super().__init__(session, date_=date_) + self.from_date = from_date + self.to_date = to_date or date.today() + self.types = types + self.item_type = item_type + self.domain = domain + self.term = term + self.answer = answer + self.locale = locale + self.app_principal_domain = None + self.page = page + + def __eq__(self, other): + return self.page == other.page + + def subset(self): + return self.query() + + @property + def page_index(self): + return self.page + + def page_by_index(self, index): + return self.__class__( + session=self.session, + date_=self.date, + from_date=self.from_date, + to_date=self.to_date, + types=self.types, + item_type=self.item_type, + domain=self.domain, + term=self.term, + answer=self.answer, + locale=self.locale, + page=index + ) + + def group_items(self, items, request): + compounded = [ + id_ for item in items for id_ in getattr(item, 'elections', []) + ] + + items = dict( + votes=[v for v in items if v.type == 'vote'], + elections=[ + e for e in items if e.type in ['election', 'election_compound'] + if e.url not in compounded + ] + ) + + return items + + @staticmethod + def term_to_tsquery_string(term): + """ Returns the current search term transformed to use within + Postgres ``to_tsquery`` function. + + Removes all unwanted characters, replaces prefix matching, joins + word together using FOLLOWED BY. + """ + + def cleanup(word, whitelist_chars=',.-_'): + result = ''.join( + (c for c in word if c.isalnum() or c in whitelist_chars) + ) + return f'{result}:*' if word.endswith('*') else result + + parts = [cleanup(part) for part in (term or '').split()] + return ' <-> '.join([part for part in parts if part]) + + @staticmethod + def match_term(column, language, term): + """ Usage: + model.filter(match_term(model.col, 'german', 'my search term')) """ + document_tsvector = func.to_tsvector(language, column) + ts_query_object = func.to_tsquery(language, term) + return document_tsvector.op('@@')(ts_query_object) + + @staticmethod + def filter_text_by_locale(column, term, locale=None): + """ Returns an SqlAlchemy filter statement based on the search term. + If no locale is provided, it will use english as language. + + ``to_tsquery`` creates a tsquery value from term, which must consist of + single tokens separated by the Boolean operators + & (AND), | (OR) and ! (NOT). + + ``to_tsvector`` parses a textual document into tokens, reduces the + tokens to lexemes, and returns a tsvector which lists the lexemes + together with their positions in the document. The document is + processed according to the specified or default text search + configuration. """ + + mapping = {'de_CH': 'german', 'fr_CH': 'french', 'it_CH': 'italian', + 'rm_CH': 'english'} + return SearchableArchivedResultCollection.match_term( + column, mapping.get(locale, 'english'), term + ) + + @property + def term_filter(self): + assert self.term + assert self.locale + term = SearchableArchivedResultCollection.term_to_tsquery_string( + self.term) + # The title is a translations hybrid, .title is a shorthand + return [ + SearchableArchivedResultCollection.filter_text_by_locale( + ArchivedResult.shortcode, term, self.locale), + SearchableArchivedResultCollection.filter_text_by_locale( + ArchivedResult.title, term, self.locale) + ] + + def check_from_date_to_date(self): + if not self.to_date or not self.from_date: + return + + if self.to_date > date.today(): + self.to_date = date.today() + if self.from_date > self.to_date: + self.from_date = self.to_date + + def query(self, no_filter=False, sort=True): + if no_filter: + return self.session.query(ArchivedResult) + + self.check_from_date_to_date() + assert self.to_date, 'to_date must have a datetime.date value' + allowed_domains = [d[0] for d in ArchivedResult.types_of_domains] + allowed_types = [t[0] for t in ArchivedResult.types_of_results] + allowed_answers = [a[0] for a in ArchivedResult.types_of_answers] + order = ('federation', 'canton', 'region', 'municipality') + if self.app_principal_domain == 'municipality': + order = ('municipality', 'federation', 'canton', 'region') + + def generate_cases(): + return tuple( + (ArchivedResult.domain == opt, ind + 1) for + ind, opt in enumerate(order) + ) + + query = self.session.query(ArchivedResult) + + if self.item_type and self.item_type in allowed_types: + # Treat compound election as elections + if self.item_type == 'vote': + query = query.filter(ArchivedResult.type == self.item_type) + else: + query = query.filter(ArchivedResult.type.in_( + ('election', 'election_compound') + )) + + elif self.types and len(self.types) != len(allowed_types): + query = query.filter(ArchivedResult.type.in_(self.types)) + if self.domain and (len(self.domain) != len(allowed_domains)): + query = query.filter(ArchivedResult.domain.in_(self.domain)) + if self.from_date: + query = query.filter(ArchivedResult.date >= self.from_date) + if self.to_date != date.today(): + query = query.filter(ArchivedResult.date <= self.to_date) + + is_vote = (self.item_type == 'vote' + or (self.types and 'vote' in self.types)) + + answer_matters = ( + self.answer and len(self.answer) != len(allowed_answers)) + + if answer_matters and is_vote: + vote_answer = ArchivedResult.meta['answer'].astext + query = query.filter( + ArchivedResult.type == 'vote', + vote_answer.in_(self.answer)) + if self.term: + query = query.filter(or_(*self.term_filter)) + + if sort: + query = query.order_by( + ArchivedResult.date.desc(), + case(generate_cases()) + ) + return query + + def reset_query_params(self): + self.from_date = None + self.to_date = date.today() + self.types = None + self.item_type = None + self.domain = None + self.term = None + self.answer = None + self.locale = 'de_CH' diff --git a/onegov/election_day/forms/__init__.py b/onegov/election_day/forms/__init__.py index 7bc53a1e..ac665318 100644 --- a/onegov/election_day/forms/__init__.py +++ b/onegov/election_day/forms/__init__.py @@ -16,9 +16,11 @@ from onegov.election_day.forms.upload import UploadWabstiProporzElectionForm from onegov.election_day.forms.upload import UploadWabstiVoteForm from onegov.election_day.forms.vote import VoteForm +from onegov.election_day.forms.archive import ArchiveSearchForm __all__ = [ + 'ArchiveSearchForm', 'DataSourceForm', 'DataSourceItemForm', 'ElectionCompoundForm', diff --git a/onegov/election_day/forms/archive.py b/onegov/election_day/forms/archive.py new file mode 100644 index 00000000..6a6d68bc --- /dev/null +++ b/onegov/election_day/forms/archive.py @@ -0,0 +1,85 @@ +from onegov.election_day.models import ArchivedResult +from onegov.form import Form +from onegov.form.fields import MultiCheckboxField +from wtforms.fields.html5 import DateField +from wtforms import StringField +from onegov.election_day import _ + + +class ArchiveSearchForm(Form): + + term = StringField( + label=_("Text Retrieval"), + render_kw={'size': 4, 'clear': True}, + description=_( + "Searches the title of the election/vote. " + "Use Wilcards (*) to find more results, e.g Nationalrat*." + ), + ) + + from_date = DateField( + label=_("From date"), + render_kw={'size': 4} + ) + + to_date = DateField( + label=_("To date"), + render_kw={'size': 4, 'clear': False} + ) + + answer = MultiCheckboxField( + label=_("Voting result"), + choices=ArchivedResult.types_of_answers, + render_kw={'size': 4} + ) + + # Is always hidden since item_type in url will filter the types + types = MultiCheckboxField( + label=_("Type"), + render_kw={'size': 4, 'clear': False, 'hidden': True}, + choices=ArchivedResult.types_of_results, + description=_( + "Compound of elections field summarizes all related elections" + " in one. To display all elections," + " uncheck 'Compound of Elections'") + ) + + domain = MultiCheckboxField( + label=_("Domain"), + render_kw={'size': 8, 'clear': False}, + choices=ArchivedResult.types_of_domains + ) + + def on_request(self): + # Roves crf token from query params + if hasattr(self, 'csrf_token'): + self.delete_field('csrf_token') + + def select_all(self, name): + field = getattr(self, name) + if not field.data: + field.data = list(next(zip(*field.choices))) + + def toggle_hidden_fields(self, model): + """ Hides answer field for election view and move the field to + the right side with render_kw. """ + if model.item_type in ('election', 'election_compound'): + self.answer.render_kw['hidden'] = True + self.domain.render_kw['size'] = 12 + else: + self.domain.render_kw['size'] = 8 + self.answer.render_kw['hidden'] = False + + def apply_model(self, model): + + self.term.data = model.term + self.from_date.data = model.from_date + self.to_date.data = model.to_date + self.answer.data = model.answer + self.types.data = model.types + self.domain.data = model.domain + + self.select_all('domain') + self.select_all('types') + self.select_all('answer') + self.toggle_hidden_fields(model) diff --git a/onegov/election_day/layouts/archive.py b/onegov/election_day/layouts/archive.py new file mode 100644 index 00000000..d4dc5273 --- /dev/null +++ b/onegov/election_day/layouts/archive.py @@ -0,0 +1,32 @@ +from onegov.election_day.collections import SearchableArchivedResultCollection +from onegov.election_day.layouts import DefaultLayout +from cached_property import cached_property +from onegov.election_day.models import ArchivedResult + + +class ArchiveLayout(DefaultLayout): + + def __init__(self, model, request): + super().__init__(model, request) + + @cached_property + def menu(self): + + return [ + (label, self.link_for(abbrev), self.model.item_type == abbrev) + for abbrev, label in ArchivedResult.types_of_results[0:2] + ] + + @cached_property + def tab_menu_title(self): + mapping = {k: v for k, v in ArchivedResult.types_of_results} + return mapping.get(self.model.item_type, 'ERROR!') + + def link_for(self, item_type): + return self.request.link( + SearchableArchivedResultCollection( + self.request.session, + item_type=item_type)) + + def instance_link(self): + return self.link_for(self.model.item_type) diff --git a/onegov/election_day/locale/de_CH/LC_MESSAGES/onegov.election_day.po b/onegov/election_day/locale/de_CH/LC_MESSAGES/onegov.election_day.po index 8866c05d..1d3cc652 100644 --- a/onegov/election_day/locale/de_CH/LC_MESSAGES/onegov.election_day.po +++ b/onegov/election_day/locale/de_CH/LC_MESSAGES/onegov.election_day.po @@ -3,8 +3,8 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-07-25 11:09+0200\n" -"PO-Revision-Date: 2019-07-25 11:13+0200\n" +"POT-Creation-Date: 2019-08-14 10:29+0200\n" +"PO-Revision-Date: 2019-08-14 10:42+0200\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" "Language: de_CH\n" @@ -289,6 +289,14 @@ msgstr "Die CSV/XLS/XLSX Date ist leer." msgid "The file contains an empty line." msgstr "Die Datei enthält eine leere Zeile." +#, python-format +msgid "Invalid integer: ${col}" +msgstr "Ungültige Ganzzahl: ${col}" + +#, python-format +msgid "Empty value: ${col}" +msgstr "Leerer Wert: ${col}" + msgid "Not a valid xls/xlsx file." msgstr "Keine gültige XLS/XLSX Datei." @@ -303,10 +311,6 @@ msgstr "Fehlende Spalte: ${col}" msgid "Value ${col} is not between 0 and 3" msgstr "Wert ${col} ist nicht zwischen 0 und 3" -#, python-format -msgid "Invalid integer: ${col}" -msgstr "Ungültige Ganzzahl: ${col}" - #, python-format msgid "Invalid integer: ${col} Can not extract entity_id." msgstr "Ungültige Ganzzahl: ${col} entity_id konnte nicht abgeleitet werden." @@ -326,14 +330,20 @@ msgstr "${name} kommt zweimal vor" msgid "Invalid entity values" msgstr "Ungültige Daten" +#, python-format +msgid "Entity with id ${id} not in added_entities" +msgstr "Wahl mit id ${id} nicht in added_entities" + msgid "Invalid candidate values" msgstr "Ungültige Kandidierendendaten" -msgid "Unknown derived list id" -msgstr "Unbekannte abgeleitete list id" +#, python-format +msgid "List_id ${list_id} has not been found in list numbers" +msgstr "List_id ${list_id} nicht gefunden in list numbers" -msgid "Invalid candidate results" -msgstr "Ungültige Kandidierendenresultate" +#, python-format +msgid "Candidate with id ${id} not in wpstatic_kandidaten" +msgstr "Kandidat mit id ${id} nicht in wpstatic_kandidaten" msgid "No clear district" msgstr "Kein eindeutiger Wahlkreis" @@ -347,17 +357,22 @@ msgstr "Ungültiger Status" msgid "Invalid election values" msgstr "Ungültige Wahldaten" +msgid "Invalid candidate results" +msgstr "Ungültige Kandidierendenresultate" + msgid "No data found" msgstr "Keine Daten gefunden" -msgid "Invalid values" -msgstr "Ungültige Werte" +msgid "Value of ausmittlungsstand not between 0 and 3" +msgstr "Wert von ausmittlungsstand nicht zwischen 0 and 3" -msgid "Invalid id" -msgstr "Ungültige ID" +#, python-format +msgid "Candidate with id ${id} not in wm_kandidaten" +msgstr "Kandidat mit id ${id} nicht in wm_kandidaten" -msgid "Could not read the eligible voters" -msgstr "Konnte 'Stimmberechtigte' nicht lesen" +#, python-format +msgid "Entity with id ${id} not in wmstatic_gemeinden" +msgstr "Gemeinde mit id ${id} nicht in wmstatic_gemeinden" msgid "Invalid list values" msgstr "Ungültige Listendaten" @@ -368,6 +383,9 @@ msgstr "Ungültige Listenresultate" msgid "Invalid list connection values" msgstr "Ungültige Listenverbindungsdaten" +msgid "Invalid values" +msgstr "Ungültige Werte" + msgid "Elected Candidates" msgstr "Gewählte Kandidierende" @@ -377,12 +395,18 @@ msgstr "Unbekannter Kandidierender" msgid "Invalid ballot type" msgstr "Ungültige Abstimmungsart" +msgid "Invalid id" +msgstr "Ungültige ID" + msgid "Could not read yeas" msgstr "Konnte 'Ja Stimmen' nicht lesen" msgid "Could not read nays" msgstr "Konnte 'Nein Stimmen' nicht lesen" +msgid "Could not read the eligible voters" +msgstr "Konnte 'Stimmberechtigte' nicht lesen" + msgid "Could not read the empty votes" msgstr "Konnte 'Leere Stimmzettel' nicht lesen" @@ -577,6 +601,36 @@ msgstr "Bitte verwenden Sie das folgende Format:" msgid "Unsubscribe" msgstr "Abmelden" +msgid "Search in ${title}" +msgstr "Suche in ${title}" + +msgid "Found ${item_count} items." +msgstr "${item_count} Einträge gefunden." + +msgid "Vote" +msgstr "Abstimmung" + +msgid "Date" +msgstr "Datum" + +msgid "Domain" +msgstr "Ebene" + +msgid "Updated" +msgstr "Aktualisiert" + +msgid "Federal" +msgstr "National" + +msgid "Regional" +msgstr "Regional" + +msgid "Cantonal" +msgstr "Kantonal" + +msgid "Communal" +msgstr "Kommunal" + msgid "" "Successfully subscribed to the email service. You will receive an email " "every time new results are published." @@ -615,15 +669,12 @@ msgstr "Kantonale Abstimmungen" msgid "Communal Votes" msgstr "Kommunale Abstimmungen" -msgid "Updated" -msgstr "Aktualisiert" - -msgid "Vote" -msgstr "Abstimmung" - msgid "Archive" msgstr "Archiv" +msgid "Archive Search" +msgstr "Archive-Suche" + msgid "Login" msgstr "Anmelden" @@ -713,24 +764,9 @@ msgstr "Titel" msgid "Shortcode" msgstr "Kürzel" -msgid "Date" -msgstr "Datum" - msgid "Type" msgstr "Art" -msgid "Federal" -msgstr "National" - -msgid "Regional" -msgstr "Regional" - -msgid "Cantonal" -msgstr "Kantonal" - -msgid "Communal" -msgstr "Kommunal" - msgid "View" msgstr "Anzeigen" @@ -1109,6 +1145,30 @@ msgstr "Ansichten" msgid "Select either majorz or proporz elections." msgstr "Wählen Sie entweder Majorz- oder Proporzwahlen." +msgid "Text Retrieval" +msgstr "Suchbegriff" + +msgid "" +"Searches the title of the election/vote. Use Wilcards (*) to find more " +"results, e.g Nationalrat*." +msgstr "" +"Durchsucht den Titel der Wahl/Abstimmung. Benutzen Sie Wildcards (*), um " +"mehr Resultate zu finden, z.B. Nationalrat*." + +msgid "From date" +msgstr "Von Datum" + +msgid "To date" +msgstr "Bis Datum" + +msgid "Voting result" +msgstr "Wahlresultat" + +msgid "" +"Compound of elections field summarizes all related elections in one. To " +"display all elections, uncheck 'Compound of Elections'" +msgstr "" + msgid "Title of the the vote/proposal" msgstr "Titel der Abstimmung/der Vorlage" @@ -1196,3 +1256,15 @@ msgstr "Stadtteil" msgid "Quarters" msgstr "Stadtteile" + +#~ msgid "Found ${count} items." +#~ msgstr "${count} Einträge gefunden." + +#~ msgid "Search in ${layout.tab_menu_title}" +#~ msgstr "Suche in ${layout.tab_menu_title}" + +#~ msgid "Derived list_id has not been found in list numbers" +#~ msgstr "Abgeleitete Listennr. unbekannt" + +#~ msgid "Unknown derived list id" +#~ msgstr "Unbekannte abgeleitete list id" diff --git a/onegov/election_day/locale/fr_CH/LC_MESSAGES/onegov.election_day.po b/onegov/election_day/locale/fr_CH/LC_MESSAGES/onegov.election_day.po index 728e1baf..fbe02aa5 100644 --- a/onegov/election_day/locale/fr_CH/LC_MESSAGES/onegov.election_day.po +++ b/onegov/election_day/locale/fr_CH/LC_MESSAGES/onegov.election_day.po @@ -3,8 +3,8 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-07-25 11:09+0200\n" -"PO-Revision-Date: 2019-07-25 11:21+0200\n" +"POT-Creation-Date: 2019-08-14 10:29+0200\n" +"PO-Revision-Date: 2019-08-14 11:01+0200\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: \n" "Language: fr_CH\n" @@ -286,6 +286,14 @@ msgstr "Fichier csv/xls/xlsx vide." msgid "The file contains an empty line." msgstr "Le fichier contient une ligne vide." +#, python-format +msgid "Invalid integer: ${col}" +msgstr "Intègre non valide: ${col}" + +#, python-format +msgid "Empty value: ${col}" +msgstr "Valeur vide: ${col}" + msgid "Not a valid xls/xlsx file." msgstr "Fichier xls/xlsx non valide." @@ -300,10 +308,6 @@ msgstr "Intègre non valid: ${col}" msgid "Value ${col} is not between 0 and 3" msgstr "Valeur ${col} n'est pas entry 0 et 3" -#, python-format -msgid "Invalid integer: ${col}" -msgstr "Intègre non valide: ${col}" - #, python-format msgid "Invalid integer: ${col} Can not extract entity_id." msgstr "Intègre non valide: ${col} entity_id n'a pas pu être derivé." @@ -323,14 +327,20 @@ msgstr "${name} a été trouvé en double" msgid "Invalid entity values" msgstr "Valeurs non valides" +#, python-format +msgid "Entity with id ${id} not in added_entities" +msgstr "Commune avec id ${id} pas dans added_entities" + msgid "Invalid candidate values" msgstr "Valeurs des candidats non valides" -msgid "Unknown derived list id" -msgstr "list id derivé inconnue" +#, python-format +msgid "List_id ${list_id} has not been found in list numbers" +msgstr "List_id ${list_id} pas trouvé dans list numbers" -msgid "Invalid candidate results" -msgstr "Résultats des candidats non valides" +#, python-format +msgid "Candidate with id ${id} not in wpstatic_kandidaten" +msgstr "Candidate avec id ${id} pas dans wpstatic_kandidaten" msgid "No clear district" msgstr "Aucun district clair" @@ -344,17 +354,22 @@ msgstr "Statut non valide" msgid "Invalid election values" msgstr "Valeurs des élections non valides" +msgid "Invalid candidate results" +msgstr "Résultats des candidats non valides" + msgid "No data found" msgstr "Pas de données trouvées" -msgid "Invalid values" -msgstr "Valeurs non valides" +msgid "Value of ausmittlungsstand not between 0 and 3" +msgstr "Valeur de ausmittlungsstand pas entre 0 et 3" -msgid "Invalid id" -msgstr "Mauvaise référence" +#, python-format +msgid "Candidate with id ${id} not in wm_kandidaten" +msgstr "Candidate avec id ${id} pas dans wm_kandidaten" -msgid "Could not read the eligible voters" -msgstr "Impossible de connaître les électeurs valides" +#, python-format +msgid "Entity with id ${id} not in wmstatic_gemeinden" +msgstr "Commune avec id ${id} pas dans wmstatic_gemeinden" msgid "Invalid list values" msgstr "Valeurs de la liste non valides" @@ -365,6 +380,9 @@ msgstr "Résultats de la liste non valides" msgid "Invalid list connection values" msgstr "Valeurs de connexion de liste non valides" +msgid "Invalid values" +msgstr "Valeurs non valides" + msgid "Elected Candidates" msgstr "Candidats élus" @@ -374,12 +392,18 @@ msgstr "Candidat inconnu" msgid "Invalid ballot type" msgstr "Type de scrutin invalide" +msgid "Invalid id" +msgstr "Mauvaise référence" + msgid "Could not read yeas" msgstr "Impossible de lire les oui" msgid "Could not read nays" msgstr "Impossible de lire les non" +msgid "Could not read the eligible voters" +msgstr "Impossible de connaître les électeurs valides" + msgid "Could not read the empty votes" msgstr "Impossible de connaître les votes blancs" @@ -577,6 +601,36 @@ msgstr "Veuillez utiliser le format suivant:" msgid "Unsubscribe" msgstr "Se désabonner" +msgid "Search in ${title}" +msgstr "Recherche dans ${title}" + +msgid "Found ${item_count} items." +msgstr "${item_count} entrées trouvés." + +msgid "Vote" +msgstr "Votation" + +msgid "Date" +msgstr "Date" + +msgid "Domain" +msgstr "Echelon" + +msgid "Updated" +msgstr "Actualisé" + +msgid "Federal" +msgstr "Fédéral" + +msgid "Regional" +msgstr "Régional" + +msgid "Cantonal" +msgstr "Cantonal" + +msgid "Communal" +msgstr "Municipal" + msgid "" "Successfully subscribed to the email service. You will receive an email " "every time new results are published." @@ -615,15 +669,12 @@ msgstr "Votations cantonales" msgid "Communal Votes" msgstr "Votations communales" -msgid "Updated" -msgstr "Actualisé" - -msgid "Vote" -msgstr "Votation" - msgid "Archive" msgstr "Archive" +msgid "Archive Search" +msgstr "Recherche dans les archives" + msgid "Login" msgstr "Se connecter" @@ -713,24 +764,9 @@ msgstr "Titre" msgid "Shortcode" msgstr "Shortcode" -msgid "Date" -msgstr "Date" - msgid "Type" msgstr "Type" -msgid "Federal" -msgstr "Fédéral" - -msgid "Regional" -msgstr "Régional" - -msgid "Cantonal" -msgstr "Cantonal" - -msgid "Communal" -msgstr "Municipal" - msgid "View" msgstr "Voir" @@ -1114,6 +1150,30 @@ msgstr "Vues" msgid "Select either majorz or proporz elections." msgstr "Sélectionnez les élections majeures ou proportionnelles." +msgid "Text Retrieval" +msgstr "Terme de recherche" + +msgid "" +"Searches the title of the election/vote. Use Wilcards (*) to find more " +"results, e.g Nationalrat*." +msgstr "" +"Recherche le titre de l'élection ou de la votation. Utilisez des caractères " +"génériques (*) pour trouver plus de résultats, par example conseil*." + +msgid "From date" +msgstr "De" + +msgid "To date" +msgstr "A" + +msgid "Voting result" +msgstr "Résultat de la votation" + +msgid "" +"Compound of elections field summarizes all related elections in one. To " +"display all elections, uncheck 'Compound of Elections'" +msgstr "" + msgid "Title of the the vote/proposal" msgstr "Titre du vote / de l'initiative" @@ -1201,3 +1261,6 @@ msgstr "Quartier" msgid "Quarters" msgstr "Quartiers" + +#~ msgid "Unknown derived list id" +#~ msgstr "list id derivé inconnue" diff --git a/onegov/election_day/locale/it_CH/LC_MESSAGES/onegov.election_day.po b/onegov/election_day/locale/it_CH/LC_MESSAGES/onegov.election_day.po index cec3fdd2..927e771d 100644 --- a/onegov/election_day/locale/it_CH/LC_MESSAGES/onegov.election_day.po +++ b/onegov/election_day/locale/it_CH/LC_MESSAGES/onegov.election_day.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-07-25 11:09+0200\n" +"POT-Creation-Date: 2019-08-14 10:29+0200\n" "PO-Revision-Date: 2019-07-25 11:28+0200\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: \n" @@ -286,6 +286,14 @@ msgstr "Il file CSV/XLS/XLSX è vuoto." msgid "The file contains an empty line." msgstr "Il file contiene una riga vuota." +#, python-format +msgid "Invalid integer: ${col}" +msgstr "numero intero non valido: ${col}" + +#, python-format +msgid "Empty value: ${col}" +msgstr "" + msgid "Not a valid xls/xlsx file." msgstr "Nessun file XLS/XLSX valido." @@ -300,10 +308,6 @@ msgstr "Colonna mancante: ${col}" msgid "Value ${col} is not between 0 and 3" msgstr "valore ${col} non compreso tra 0 e 3" -#, python-format -msgid "Invalid integer: ${col}" -msgstr "numero intero non valido: ${col}" - #, python-format msgid "Invalid integer: ${col} Can not extract entity_id." msgstr "numero intero non valido: ${col} Non è possibile estrarre entity_id." @@ -323,14 +327,20 @@ msgstr "${name} figura due volte" msgid "Invalid entity values" msgstr "Valori non validi" +#, python-format +msgid "Entity with id ${id} not in added_entities" +msgstr "" + msgid "Invalid candidate values" msgstr "Valori del candidato non validi" -msgid "Unknown derived list id" -msgstr "list id derivato sconosciuto" +#, python-format +msgid "List_id ${list_id} has not been found in list numbers" +msgstr "" -msgid "Invalid candidate results" -msgstr "Risultati del candidato non validi" +#, python-format +msgid "Candidate with id ${id} not in wpstatic_kandidaten" +msgstr "" msgid "No clear district" msgstr "Nessun distretto ben definito" @@ -344,17 +354,22 @@ msgstr "Stato non valido" msgid "Invalid election values" msgstr "Valori dell'elezione non validi" +msgid "Invalid candidate results" +msgstr "Risultati del candidato non validi" + msgid "No data found" msgstr "Nessun dato trovato" -msgid "Invalid values" -msgstr "Valori non validi" +msgid "Value of ausmittlungsstand not between 0 and 3" +msgstr "" -msgid "Invalid id" -msgstr "ID non valido" +#, python-format +msgid "Candidate with id ${id} not in wm_kandidaten" +msgstr "" -msgid "Could not read the eligible voters" -msgstr "Impossibile leggere 'Aventi diritto di voto'" +#, python-format +msgid "Entity with id ${id} not in wmstatic_gemeinden" +msgstr "" msgid "Invalid list values" msgstr "Valori della lista non validi" @@ -365,6 +380,9 @@ msgstr "Risultati della lista non validi" msgid "Invalid list connection values" msgstr "Valori della lista delle connessioni non validi" +msgid "Invalid values" +msgstr "Valori non validi" + msgid "Elected Candidates" msgstr "Candidati eletti" @@ -374,12 +392,18 @@ msgstr "Candidato non conosciuto" msgid "Invalid ballot type" msgstr "Tipo di scheda non valido" +msgid "Invalid id" +msgstr "ID non valido" + msgid "Could not read yeas" msgstr "Impossibile leggere 'SÌ'" msgid "Could not read nays" msgstr "Impossibile leggere 'NO'" +msgid "Could not read the eligible voters" +msgstr "Impossibile leggere 'Aventi diritto di voto'" + msgid "Could not read the empty votes" msgstr "Impossibile leggere 'Schede bianche'" @@ -575,6 +599,36 @@ msgstr "Utilizzi per favore il formato seguente:" msgid "Unsubscribe" msgstr "Annulla iscrizione" +msgid "Search in ${title}" +msgstr "" + +msgid "Found ${item_count} items." +msgstr "" + +msgid "Vote" +msgstr "Votazione" + +msgid "Date" +msgstr "Data" + +msgid "Domain" +msgstr "" + +msgid "Updated" +msgstr "Aggiornato" + +msgid "Federal" +msgstr "Nazionale" + +msgid "Regional" +msgstr "Regionale" + +msgid "Cantonal" +msgstr "Cantonale" + +msgid "Communal" +msgstr "Comunale" + msgid "" "Successfully subscribed to the email service. You will receive an email " "every time new results are published." @@ -613,15 +667,12 @@ msgstr "Votazioni cantonali" msgid "Communal Votes" msgstr "Votazioni commuali" -msgid "Updated" -msgstr "Aggiornato" - -msgid "Vote" -msgstr "Votazione" - msgid "Archive" msgstr "Archivio" +msgid "Archive Search" +msgstr "" + msgid "Login" msgstr "Login" @@ -709,24 +760,9 @@ msgstr "Titolo" msgid "Shortcode" msgstr "Abbreviazione" -msgid "Date" -msgstr "Data" - msgid "Type" msgstr "Tipo" -msgid "Federal" -msgstr "Nazionale" - -msgid "Regional" -msgstr "Regionale" - -msgid "Cantonal" -msgstr "Cantonale" - -msgid "Communal" -msgstr "Comunale" - msgid "View" msgstr "Mostrare" @@ -1111,6 +1147,28 @@ msgstr "Visualizzazioni" msgid "Select either majorz or proporz elections." msgstr "Seleziona o elezioni maggioritarie o proporzionali." +msgid "Text Retrieval" +msgstr "" + +msgid "" +"Searches the title of the election/vote. Use Wilcards (*) to find more " +"results, e.g Nationalrat*." +msgstr "" + +msgid "From date" +msgstr "" + +msgid "To date" +msgstr "" + +msgid "Voting result" +msgstr "" + +msgid "" +"Compound of elections field summarizes all related elections in one. To " +"display all elections, uncheck 'Compound of Elections'" +msgstr "" + msgid "Title of the the vote/proposal" msgstr "Titolo del voto/iniziativa" @@ -1198,3 +1256,6 @@ msgstr "Quartiere" msgid "Quarters" msgstr "Quartieri" + +#~ msgid "Unknown derived list id" +#~ msgstr "list id derivato sconosciuto" diff --git a/onegov/election_day/locale/rm_CH/LC_MESSAGES/onegov.election_day.po b/onegov/election_day/locale/rm_CH/LC_MESSAGES/onegov.election_day.po index f56a5b55..85821732 100644 --- a/onegov/election_day/locale/rm_CH/LC_MESSAGES/onegov.election_day.po +++ b/onegov/election_day/locale/rm_CH/LC_MESSAGES/onegov.election_day.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2019-07-22 13:32+0200\n" +"POT-Creation-Date: 2019-08-14 10:29+0200\n" "PO-Revision-Date: 2018-11-05 12:07+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: \n" @@ -289,6 +289,14 @@ msgstr "La datoteca CSV/XLS/XLSX è vida." msgid "The file contains an empty line." msgstr "La datoteca cuntegna ina lingia vida." +#, python-format +msgid "Invalid integer: ${col}" +msgstr "" + +#, python-format +msgid "Empty value: ${col}" +msgstr "" + msgid "Not a valid xls/xlsx file." msgstr "Nagina datoteca XLS/XLSX valaivla." @@ -303,10 +311,6 @@ msgstr "" msgid "Value ${col} is not between 0 and 3" msgstr "" -#, python-format -msgid "Invalid integer: ${col}" -msgstr "" - #, python-format msgid "Invalid integer: ${col} Can not extract entity_id." msgstr "" @@ -326,14 +330,20 @@ msgstr "${name} è avant maun duas giadas" msgid "Invalid entity values" msgstr "Datas nunvalaivlas" +#, python-format +msgid "Entity with id ${id} not in added_entities" +msgstr "" + msgid "Invalid candidate values" msgstr "Datas da candidat nunvalaivlas" -msgid "Unknown derived list id" +#, python-format +msgid "List_id ${list_id} has not been found in list numbers" msgstr "" -msgid "Invalid candidate results" -msgstr "Resultats da candidat nunvalaivels" +#, python-format +msgid "Candidate with id ${id} not in wpstatic_kandidaten" +msgstr "" msgid "No clear district" msgstr "Nagina clera regiun" @@ -347,17 +357,22 @@ msgstr "Status nunvalaivel" msgid "Invalid election values" msgstr "Datas electoralas nunvalaivlas" +msgid "Invalid candidate results" +msgstr "Resultats da candidat nunvalaivels" + msgid "No data found" msgstr "Chattà naginas datas" -msgid "Invalid values" -msgstr "Valurs nunvalaivlas" +msgid "Value of ausmittlungsstand not between 0 and 3" +msgstr "" -msgid "Invalid id" -msgstr "ID nunvalaivel" +#, python-format +msgid "Candidate with id ${id} not in wm_kandidaten" +msgstr "" -msgid "Could not read the eligible voters" -msgstr "I n'è betg stà pussaivel da leger las persunas cun dretg da votar" +#, python-format +msgid "Entity with id ${id} not in wmstatic_gemeinden" +msgstr "" msgid "Invalid list values" msgstr "Datas da glista nunvalaivlas" @@ -368,6 +383,9 @@ msgstr "Resultats da glistas nunvalaivels" msgid "Invalid list connection values" msgstr "Datas nunvalaivlas da colliaziuns da glistas" +msgid "Invalid values" +msgstr "Valurs nunvalaivlas" + msgid "Elected Candidates" msgstr "Candidat(a)s elegid(a)s" @@ -377,12 +395,18 @@ msgstr "Candidat(a) nunenconuschent(a)" msgid "Invalid ballot type" msgstr "Tip da votaziun nunvalaivel" +msgid "Invalid id" +msgstr "ID nunvalaivel" + msgid "Could not read yeas" msgstr "I n'è betg stà pussaivel da leger las vuschs affirmativas" msgid "Could not read nays" msgstr "I n'è betg stà pussaivel da leger las vuschs negativas" +msgid "Could not read the eligible voters" +msgstr "I n'è betg stà pussaivel da leger las persunas cun dretg da votar" + msgid "Could not read the empty votes" msgstr "I n'è betg stà pussaivel da leger ils cedels da votar vids" @@ -580,6 +604,36 @@ msgstr "Duvrai per plaschair il suandant format:" msgid "Unsubscribe" msgstr "Deconnectar" +msgid "Search in ${title}" +msgstr "" + +msgid "Found ${item_count} items." +msgstr "" + +msgid "Vote" +msgstr "Votaziun" + +msgid "Date" +msgstr "Data" + +msgid "Domain" +msgstr "" + +msgid "Updated" +msgstr "Actualisà" + +msgid "Federal" +msgstr "Sin plaun naziunal" + +msgid "Regional" +msgstr "Sin plaun regiunal" + +msgid "Cantonal" +msgstr "Sin plaun chantunal" + +msgid "Communal" +msgstr "Sin plaun communal" + msgid "" "Successfully subscribed to the email service. You will receive an email " "every time new results are published." @@ -618,15 +672,12 @@ msgstr "Votaziuns chantunalas" msgid "Communal Votes" msgstr "Votaziuns communalas" -msgid "Updated" -msgstr "Actualisà" - -msgid "Vote" -msgstr "Votaziun" - msgid "Archive" msgstr "Archiv" +msgid "Archive Search" +msgstr "" + msgid "Login" msgstr "Annunziar" @@ -716,24 +767,9 @@ msgstr "Titel" msgid "Shortcode" msgstr "Scursanida" -msgid "Date" -msgstr "Data" - msgid "Type" msgstr "Gener" -msgid "Federal" -msgstr "Sin plaun naziunal" - -msgid "Regional" -msgstr "Sin plaun regiunal" - -msgid "Cantonal" -msgstr "Sin plaun chantunal" - -msgid "Communal" -msgstr "Sin plaun communal" - msgid "View" msgstr "Mussar" @@ -1083,6 +1119,21 @@ msgstr "Talian" msgid "Romansh" msgstr "Rumantsch" +msgid "Link" +msgstr "" + +msgid "Link label german" +msgstr "" + +msgid "Link label french" +msgstr "" + +msgid "Link label italian" +msgstr "" + +msgid "Link label romansh" +msgstr "" + msgid "Absolute" msgstr "Absolut" @@ -1104,6 +1155,28 @@ msgstr "Vistas" msgid "Select either majorz or proporz elections." msgstr "Tscherni u las elecziuns da maiorz u las elecziuns da proporz." +msgid "Text Retrieval" +msgstr "" + +msgid "" +"Searches the title of the election/vote. Use Wilcards (*) to find more " +"results, e.g Nationalrat*." +msgstr "" + +msgid "From date" +msgstr "" + +msgid "To date" +msgstr "" + +msgid "Voting result" +msgstr "" + +msgid "" +"Compound of elections field summarizes all related elections in one. To " +"display all elections, uncheck 'Compound of Elections'" +msgstr "" + msgid "Title of the the vote/proposal" msgstr "Titel da la votaziun / dal project" diff --git a/onegov/election_day/models/archived_result.py b/onegov/election_day/models/archived_result.py index a281925d..9de4f04a 100644 --- a/onegov/election_day/models/archived_result.py +++ b/onegov/election_day/models/archived_result.py @@ -16,6 +16,7 @@ from sqlalchemy import Integer from sqlalchemy import Text from uuid import uuid4 +from onegov.election_day import _ meta_local_property = dictionary_based_property_factory('local') @@ -28,6 +29,25 @@ class ArchivedResult(Base, ContentMixin, TimestampMixin, __tablename__ = 'archived_results' + types_of_results = ( + ('vote', _("Vote")), + ('election', _("Election")), + ('election_compound', _("Compounds of elections")) + ) + # see also the DomainOfInfluenceMixin.allowed_domains + types_of_domains = ( + ('federation', _("Federal")), + ('canton', _("Cantonal")), + ('region', _("Regional")), + ('municipality', _("Municipality")) + ) + + types_of_answers = ( + ('accepted', _("Accepted")), + ('rejected', _("Rejected")), + ('counter_proposal', _("Counter Proposal")) + ) + #: Identifies the result id = Column(UUID, primary_key=True, default=uuid4) @@ -43,9 +63,7 @@ class ArchivedResult(Base, ContentMixin, TimestampMixin, #: Type of the result type = Column( Enum( - 'election', - 'election_compound', - 'vote', + *(f[0] for f in types_of_results), name='type_of_result' ), nullable=False @@ -65,7 +83,7 @@ class ArchivedResult(Base, ContentMixin, TimestampMixin, @property def progress(self): - return (self.counted_entities or 0, self.total_entities or 0) + return self.counted_entities or 0, self.total_entities or 0 #: The link to the detailed results url = Column(Text, nullable=False) diff --git a/onegov/election_day/path.py b/onegov/election_day/path.py index e91895a3..5858c3ed 100644 --- a/onegov/election_day/path.py +++ b/onegov/election_day/path.py @@ -10,6 +10,7 @@ from onegov.ballot import ListCollection from onegov.ballot import Vote from onegov.ballot import VoteCollection +from onegov.core.converters import extended_date_converter from onegov.core.i18n import SiteLocale from onegov.election_day import ElectionDayApp from onegov.election_day.collections import ArchivedResultCollection @@ -19,6 +20,10 @@ from onegov.election_day.collections import SmsSubscriberCollection from onegov.election_day.collections import SubscriberCollection from onegov.election_day.collections import UploadTokenCollection +from onegov.election_day.collections.archived_results import ( + SearchableArchivedResultCollection +) + from onegov.election_day.models import DataSource from onegov.election_day.models import DataSourceItem from onegov.election_day.models import Principal @@ -141,6 +146,42 @@ def get_archive_by_year(app, date): return ArchivedResultCollection(app.session(), date) +@ElectionDayApp.path( + model=SearchableArchivedResultCollection, + path='archive-search/{item_type}', + converters=dict( + from_date=extended_date_converter, + to_date=extended_date_converter, + types=[str], + domain=[str], + answer=[str] + ) +) +def get_archive_search( + app, + from_date=None, + to_date=None, + answer=None, + types=None, + item_type=None, + domain=None, + term=None, + page=0 +): + + return SearchableArchivedResultCollection( + app.session(), + to_date=to_date, + from_date=from_date, + answer=answer, + types=types, + item_type=item_type, + domain=domain, + term=term, + page=page + ) + + @ElectionDayApp.path(model=SiteLocale, path='/locale/{locale}') def get_locale(request, app, locale, to=None): to = to or request.link(app.principal) diff --git a/onegov/election_day/templates/archive.pt b/onegov/election_day/templates/archive.pt index 37917656..aec521cb 100644 --- a/onegov/election_day/templates/archive.pt +++ b/onegov/election_day/templates/archive.pt @@ -105,15 +105,17 @@

Archive

Archive

- +
+
+

Archive Search

+
-
diff --git a/onegov/election_day/templates/archive_search.pt b/onegov/election_day/templates/archive_search.pt new file mode 100644 index 00000000..be694f00 --- /dev/null +++ b/onegov/election_day/templates/archive_search.pt @@ -0,0 +1,128 @@ +
+ +

+ ${layout.principal.name} + Elections & Votes +

+ +
+
+ + + +
+
+
+
+ +
+ +
+
+
+

Search in ${layout.tab_menu_title}

+
+
+ + Found ${item_count} items. +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
VoteDateDomainResultYes %No %CountedUpdated
+ ${prefix}: ${vote.title} + ${layout.format_date(vote.date, 'date')}FederalRegionalCantonalCommunal +
+
+ + ${layout.format_number(vote.display_yeas_percentage(request))} + + + + ${layout.format_number(vote.display_nays_percentage(request))} + + + +
+ +
+ ${layout.format_date(vote.last_result_change, 'datetime')} +
+
+
+
+ + + + + + + + + + + + + + + + + + + +
ElectionCountedUpdated
+ ${prefix}: ${election.title} + +
+
+ ${layout.format_date(election.last_result_change, 'datetime')} +
+
+
+
+ + +
diff --git a/onegov/election_day/templates/macros.pt b/onegov/election_day/templates/macros.pt index 5e1f28e7..962f87a3 100644 --- a/onegov/election_day/templates/macros.pt +++ b/onegov/election_day/templates/macros.pt @@ -29,7 +29,7 @@ -
+
${fieldset.label} @@ -58,32 +58,35 @@ - -
- - - ${field()} - ${error} - - -
- +
diff --git a/onegov/election_day/tests/collections/test_searchable_archived_result_collection.py b/onegov/election_day/tests/collections/test_searchable_archived_result_collection.py new file mode 100644 index 00000000..36e6bc16 --- /dev/null +++ b/onegov/election_day/tests/collections/test_searchable_archived_result_collection.py @@ -0,0 +1,266 @@ +from datetime import date + +from onegov.ballot import Vote +from onegov.election_day.collections import SearchableArchivedResultCollection +from onegov.election_day.models import ArchivedResult +from onegov.election_day.tests.common import DummyRequest + + +class TestSearchableCollection: + + available_types = [t[0] for t in ArchivedResult.types_of_results] + available_domains = [d[0] for d in ArchivedResult.types_of_domains] + available_answers = ['accepted', 'rejected', 'counter_proposal'] + + def test_initial_config(self, searchable_archive): + # Test to_date is always filled in and is type date + assert searchable_archive.to_date + assert searchable_archive.from_date is None + assert isinstance(searchable_archive.to_date, date) + + def test_initial_query(self, searchable_archive): + # Test initial query without params + assert searchable_archive.query().count() == 12 + + def test_default_values_of_form(self, searchable_archive): + # Set values like you would when going to search form first time + + archive = searchable_archive + archive.domain = self.available_domains + archive.types = self.available_types + archive.answer = self.available_answers + archive.term = '' + assert not searchable_archive.item_type + sql_query = str(archive.query(sort=False)) + assert 'archived_results.domain IN' not in sql_query + assert 'archived_results.date >=' not in sql_query + assert 'archived_results.type IN' not in sql_query + assert 'archived_results.date >= %(date_1)s AND' \ + ' archived_results.date <= %(date_2)s' not in sql_query + # test for the term that looks in title_translations + assert "archived_results.title_translations -> 'de_CH'"\ + not in sql_query + assert "archived_results.shortcode) @@ to_tsquery" not in sql_query + + def test_from_date_to_date(self, searchable_archive): + archive = searchable_archive + # Test to_date is neglected if from_date is not given + assert not archive.from_date + assert 'WHERE archived_results.date' \ + not in str(archive.query()) + + # Test query and results with a value of to_date and from_date + archive.from_date = date(2009, 1, 1) + assert 'WHERE archived_results.date' \ + in str(archive.query()) + assert archive.query().count() == 11 + + # get the 2009 election + archive.reset_query_params() + archive.from_date = date(2008, 1, 1) + archive.to_date = date(2008, 1, 2) + assert archive.query().count() == 1 + + def test_ignore_types_with_item_type(self, searchable_archive): + # Test if types is ignored when item_type is set + searchable_archive.item_type = 'election' + searchable_archive.types = ['vote'] + assert searchable_archive.query().count() != 3 + + def test_query_with_types_and_type(self, searchable_archive): + # Check if types is queried correctly + assert searchable_archive.item_type is None + searchable_archive.types = ['vote'] + assert searchable_archive.query().count() == 3 + searchable_archive.reset_query_params() + searchable_archive.item_type = 'vote' + assert searchable_archive.query().count() == 3 + + searchable_archive.reset_query_params() + + searchable_archive.types = ['election'] + assert searchable_archive.query().count() == 6 + searchable_archive.reset_query_params() + # When used with item_type, compound elections are also returned + searchable_archive.item_type = 'election' + assert searchable_archive.query().count() == 9 + searchable_archive.item_type = 'election_compound' + assert searchable_archive.query().count() == 9 + + searchable_archive.reset_query_params() + searchable_archive.types = ['vote', 'election', 'election_compound'] + assert searchable_archive.query().count() == 12 + + def test_query_with_domains(self, searchable_archive): + archive = searchable_archive + archive.domain = ['federation'] + assert archive.query().count() == 9 + + archive.domain = ['canton'] + assert archive.query().count() == 1 + + archive.domain = ['region'] + assert archive.query().count() == 1 + + archive.domain = ['municipality'] + assert archive.query().count() == 1 + + def test_query_with_voting_result(self, session, searchable_archive): + results = session.query(ArchivedResult).all() + archive = searchable_archive + for item in results: + assert not item.answer + item.answer = 'accepted' + item.type = 'vote' + # assert item.answer is None # fails since it has also '' + for item in session.query(ArchivedResult).all(): + assert item.answer == 'accepted' + + archive.types = ['vote', 'election'] + archive.answer = ['accepted'] + assert archive.query().count() == len(results) + + def test_with_term(self, searchable_archive): + # want to find the 2008 election + archive = searchable_archive + searchable_archive.term = 'election 2009' + assert archive.query().count() == 1 + + def test_with_all_params_non_default(self, searchable_archive): + # Want to receive 2009 election + archive = searchable_archive + assert not archive.item_type + all_items = archive.query().all() + # set the answers + for item in all_items: + item.answer = 'rejected' + + archive.domain = ['federation'] # filter to 10 + archive.types = ['election'] # filters to 6 + archive.answer = self.available_answers # no filter + archive.from_date = date(2009, 1, 1) + archive.to_date = date(2009, 1, 2) + archive.term = 'Election 2009' + assert archive.locale == 'de_CH' + assert archive.query().count() == 1 + + sql_query = str(archive.query()) + print(sql_query) + assert 'archived_results.domain IN' in sql_query + assert 'archived_results.date >=' in sql_query + assert 'archived_results.type IN' in sql_query + assert 'archived_results.date >= %(date_1)s AND' \ + ' archived_results.date <= %(date_2)s' in sql_query + # test for the term that looks in title_tranTslations + assert "archived_results.title_translations -> 'de_CH'" in sql_query + assert "archived_results.shortcode) @@ to_tsquery" in sql_query + + def test_query_term_only_on_locale( + self, election_day_app): + + session = election_day_app.session_manager.session() + vote = Vote(title="Vote {}".format(2012), domain='federation', + date=date(2012, 1, 1)) + vote.title_translations['de_CH'] = 'Election (de)' + vote.title_translations['fr_CH'] = 'Election (fr)' + vote.title_translations['it_CH'] = 'Election (it)' + vote.title_translations['rm_CH'] = 'Election (rm)' + session.add(vote) + session.flush() + + archive = SearchableArchivedResultCollection(session) + request = DummyRequest( + locale='de_CH', + app=election_day_app, + session=session) + + archive.update_all(request) + item = archive.query().first() + assert item.title == 'Election (de)' + + # test for different locales + + # for locale in ('de_CH', 'fr_CH', 'it_CH', 'rm_CH'): + # archive.locale = locale + # election_day_app.session_manager.current_locale = locale + # archive.term = locale[0:2] + # sql_query = str(archive.query()) + # print(sql_query) + # assert archive.query().count() == 1 + # assert f"archived_results.title_translations -> '{locale}'" \ + # in sql_query + + def test_group_items_for_archive( + self, searchable_archive): + items = searchable_archive.query().all() + request = DummyRequest() + assert request.app.principal.domain, 'DummyRequest should have domain' + g_items = searchable_archive.group_items(items, request) + votes = g_items.get('votes') + elections = g_items.get('elections') + assert len(items) == len(votes) + len(elections) + + def test_pagination(self, searchable_archive): + # Tests methods that have to be implemented for pagination parent class + assert searchable_archive.batch_size == 10 + assert len(searchable_archive.batch) == 10 + assert searchable_archive.subset_count == 12 + assert searchable_archive.page_index == 0 + assert searchable_archive.pages_count == 2 + next_ = searchable_archive.next + assert next_.page_index != searchable_archive.page_index + by_index = searchable_archive.page_by_index(2) + + for key in searchable_archive.__dict__: + if key in ('page', 'cached_subset', 'batch'): + continue + assert getattr(searchable_archive, key) == getattr(by_index, key) + + def test_query_ordering(self, searchable_archive): + + items = searchable_archive.query().all() + # test ordered by date descending + assert items[0].date == date(2019, 1, 1) + assert items[-1].date == date(2008, 1, 1) + + # subset with different domains + searchable_archive.from_date = date(2019, 1, 1) + searchable_archive.to_date = date(2019, 1, 1) + items_one_date = searchable_archive.query().all() + assert items_one_date[0].domain == 'canton' + assert items_one_date[1].domain == 'region' + assert items_one_date[2].domain == 'municipality' + + # Test for municipality + searchable_archive.app_principal_domain = 'municipality' + items_one_date = searchable_archive.query().all() + assert items_one_date[0].domain == 'municipality' + assert items_one_date[1].domain == 'canton' + assert items_one_date[2].domain == 'region' + + def test_check_from_date_to_date(self, searchable_archive): + # check_from_date_to_date is triggered in the query function + + archive = searchable_archive + + assert archive.to_date == date.today() + # both are not set + archive.check_from_date_to_date() + assert archive.from_date is None, 'Should not modify anything' + assert archive.to_date == date.today() + + # from_date bigger than to_date + archive.from_date = date(2019, 1, 1) + archive.to_date = date(2018, 1, 1) + assert archive.from_date + assert archive.to_date + + archive.check_from_date_to_date() + assert archive.from_date + assert archive.to_date + + assert archive.from_date == archive.to_date + + archive.to_date = date(2300, 1, 1) + archive.check_from_date_to_date() + assert archive.to_date == date.today() diff --git a/onegov/election_day/tests/conftest.py b/onegov/election_day/tests/conftest.py index dba00b5d..fc67f888 100644 --- a/onegov/election_day/tests/conftest.py +++ b/onegov/election_day/tests/conftest.py @@ -2,9 +2,12 @@ import pytest import textwrap import transaction - +from onegov.ballot import Election, ElectionCompound, Vote +from datetime import date from onegov.core.crypto import hash_password from onegov.election_day import ElectionDayApp +from onegov.election_day.collections import SearchableArchivedResultCollection +from onegov.election_day.tests.common import DummyRequest from onegov.user import User from onegov_testing.utils import create_app @@ -93,3 +96,46 @@ def election_day_app_kriens(request): @pytest.fixture(scope='function') def related_link_labels(): return {'de_CH': 'DE', 'fr_CH': 'FR', 'it_CH': 'IT', 'rm_CH': 'RM'} + + +@pytest.fixture(scope='function') +def searchable_archive(session): + archive = SearchableArchivedResultCollection(session) + + # Create 12 entries + for year in (2009, 2011, 2014): + session.add( + Election( + title="Election {}".format(year), + domain='federation', + date=date(year, 1, 1), + ) + ) + for year in (2008, 2012, 2016): + session.add( + ElectionCompound( + title="Elections {}".format(year), + domain='federation', + date=date(year, 1, 1), + ) + ) + for year in (2011, 2015, 2016): + session.add( + Vote( + title="Vote {}".format(year), + domain='federation', + date=date(year, 1, 1), + ) + ) + for domain in ('canton', 'region', 'municipality'): + session.add( + Election( + title="Election {}".format(domain), + domain=domain, + date=date(2019, 1, 1), + ) + ) + + session.flush() + archive.update_all(DummyRequest()) + return archive diff --git a/onegov/election_day/tests/forms/test_archive_search_form.py b/onegov/election_day/tests/forms/test_archive_search_form.py new file mode 100644 index 00000000..3604fc04 --- /dev/null +++ b/onegov/election_day/tests/forms/test_archive_search_form.py @@ -0,0 +1,23 @@ +from onegov.election_day.collections import SearchableArchivedResultCollection +from onegov.election_day.forms import ArchiveSearchForm +from datetime import date + + +def test_apply_model_archive_search_form(session): + archive = SearchableArchivedResultCollection(session) + archive.term = 'xxx' + archive.from_date = date(2222, 1, 1) + archive.to_date = date(2222, 1, 1) + archive.answer = ['accepted'] + archive.types = ['election', 'vote'] + archive.domain = ['region', 'municipality'] + + form = ArchiveSearchForm() + # form.request = DummyRequest() + form.apply_model(archive) + assert form.term.data == archive.term + assert form.from_date.data == archive.from_date + assert form.to_date.data == archive.to_date + assert form.answer.data == archive.answer + assert form.types.data == archive.types + assert form.domain.data == archive.domain diff --git a/onegov/election_day/tests/models/test_archived_result.py b/onegov/election_day/tests/models/test_archived_result.py index 6ae7272c..c98c4824 100644 --- a/onegov/election_day/tests/models/test_archived_result.py +++ b/onegov/election_day/tests/models/test_archived_result.py @@ -1,6 +1,7 @@ from datetime import date from datetime import datetime from datetime import timezone + from onegov.election_day.models import ArchivedResult from onegov.election_day.tests.common import DummyRequest @@ -212,3 +213,17 @@ def test_archived_result_local_results(session): assert result.display_answer(request) == 'rejected' assert result.display_nays_percentage(request) == 60.0 assert result.display_yeas_percentage(request) == 40.0 + + +def test_domain_types(): + domain_entries = [d[0] for d in ArchivedResult.types_of_domains] + assert sorted(domain_entries) == \ + ['canton', 'federation', 'municipality', 'region'] + + +def test_type_of_results_order(): + # Order is explicitly used in code, so test it + results = ArchivedResult.types_of_results + assert results[0][0] == 'vote' + assert results[1][0] == 'election' + assert results[2][0] == 'election_compound' diff --git a/onegov/election_day/tests/views/test_views_archive.py b/onegov/election_day/tests/views/test_views_archive.py index d83891fb..c027b66c 100644 --- a/onegov/election_day/tests/views/test_views_archive.py +++ b/onegov/election_day/tests/views/test_views_archive.py @@ -1,3 +1,4 @@ +import pytest import transaction from datetime import date @@ -173,3 +174,14 @@ def test_view_update_results(election_day_app): results = archive.query().count() == 2 assert len(client.get('/json').json['results']) == 2 + + +@pytest.mark.parametrize("url", ['vote', 'election', 'election_compound']) +def test_view_filter_archive(url, election_day_app): + client = Client(election_day_app) + client.get('/locale/de_CH').follow() + new = client.get(f'/archive-search/{url}') + assert new.form + assert new.form.method == 'GET' + resp = new.form.submit() + assert resp.status_code == 200 diff --git a/onegov/election_day/theme/styles/election_day.scss b/onegov/election_day/theme/styles/election_day.scss index 6ff2d0b0..0064f58b 100644 --- a/onegov/election_day/theme/styles/election_day.scss +++ b/onegov/election_day/theme/styles/election_day.scss @@ -28,6 +28,10 @@ html { text-transform: uppercase; } +.no-wrap { + white-space: nowrap; +} + /* Before Content */ @@ -111,13 +115,14 @@ html { ul { list-style-type: none; margin: 0; + } +} +.archive-breadcrumbs { + li { + display: inline-block; - li { - display: inline-block; - - + li::before { - content: '/ '; - } + + li::before { + content: '/ '; } } } @@ -175,6 +180,11 @@ table { &.left-aligned { text-align: left; } + + &.top-aligned { + vertical-align: top; + } + } &.tablesaw thead th.right-aligned { diff --git a/onegov/election_day/views/archive.py b/onegov/election_day/views/archive.py index 1b909c5b..74cefaa7 100644 --- a/onegov/election_day/views/archive.py +++ b/onegov/election_day/views/archive.py @@ -1,7 +1,12 @@ from onegov.core.security import Public from onegov.election_day import ElectionDayApp from onegov.election_day.collections import ArchivedResultCollection +from onegov.election_day.collections.archived_results import ( + SearchableArchivedResultCollection +) +from onegov.election_day.forms.archive import ArchiveSearchForm from onegov.election_day.layouts import DefaultLayout +from onegov.election_day.layouts.archive import ArchiveLayout from onegov.election_day.models import Principal from onegov.election_day.utils import add_cors_header from onegov.election_day.utils import add_last_modified_header @@ -23,11 +28,16 @@ def view_archive(self, request): layout = DefaultLayout(self, request) results, last_modified = self.by_date() results = self.group_items(results, request) + archive_link = request.class_link( + SearchableArchivedResultCollection, + variables={'item_type': 'vote'} + ) return { 'layout': layout, 'date': self.date, - 'archive_items': results + 'archive_items': results, + 'archive_link': archive_link } @@ -78,11 +88,16 @@ def view_principal(self, request): archive = ArchivedResultCollection(request.session) latest, last_modified = archive.latest() latest = archive.group_items(latest, request) + archive_link = request.class_link( + SearchableArchivedResultCollection, + variables={'item_type': 'vote'} + ) return { 'layout': layout, 'archive_items': latest, - 'date': None + 'date': None, + 'archive_link': archive_link } @@ -116,3 +131,39 @@ def add_headers(response): for year in archive.get_years() } } + + +@ElectionDayApp.form( + model=SearchableArchivedResultCollection, + template='archive_search.pt', + form=ArchiveSearchForm, + permission=Public, +) +def view_archive_search(self, request, form): + + """ Shows all the results from the elections and votes of the last election + day. It's the landing page. + + """ + + # layout = DefaultLayout(self, request) + layout = ArchiveLayout(self, request) + self.locale = request.locale + results = self.batch + grouped_results = self.group_items(results, request) + + if not form.errors: + form.apply_model(self) + + # set principal for query function + self.app_principal_domain = request.app.principal.domain + + return { + 'item_type': self.item_type, + 'layout': layout, + 'form': form, + 'form_method': 'GET', + 'archive_items': grouped_results, + 'item_count': self.subset_count, + 'archive_link': request.link(self) + } diff --git a/onegov/election_day/views/manage/archive.py b/onegov/election_day/views/manage/archive.py index 3dd71c10..94ac9150 100644 --- a/onegov/election_day/views/manage/archive.py +++ b/onegov/election_day/views/manage/archive.py @@ -23,7 +23,6 @@ def view_update_results(self, request, form): """ layout = DefaultLayout(self, request) - archive = ArchivedResultCollection(request.session) if form.submitted(request): archive = ArchivedResultCollection(request.session)