Skip to content
This repository has been archived by the owner on Sep 5, 2019. It is now read-only.

Archive search #159

Open
wants to merge 92 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
ee1c38e
Adds form to search ArchivedResultsCollection (archive)
Jul 30, 2019
8af0232
Adds info tooltip to fields macro and adds column sizing
Jul 30, 2019
c87ec59
Applies styles to archive_breadcrumbs class on landing page
Jul 30, 2019
c944f4f
Fixes conflict with builtin name (date, type)
Jul 30, 2019
7e92068
Adds path and Collection for seareable archive
Jul 30, 2019
4f6f195
Adds a archive search view with form directive
Jul 30, 2019
7341f40
Adds template for Archive Search Page
Jul 30, 2019
2588eb0
Fixes ul li display with class archive-breadcrumbs
Jul 30, 2019
460deb9
Fixex conflict with builtins
Jul 30, 2019
f795bf7
Fixes wrong coerce
Jul 30, 2019
a6e51bf
Adds default to_date of today to ArchiveSearchForm
Jul 30, 2019
fbc296c
Prevents populating choices if already done before
Jul 30, 2019
b72ebe3
Asserts that a default to_date will be passed from the form
Jul 30, 2019
35e693e
Removes redundant parenthesis
Jul 30, 2019
af81af7
Stores labeled types of results as accessible class attribute
Jul 30, 2019
0d7d87b
Fixes model not being applied to ArchiveSearchForm
Jul 30, 2019
7eebdea
Adds to_date as default to_date that is used in the form as default
Jul 30, 2019
92cbe0d
Adds the choices from the model instance
Jul 30, 2019
23dda20
Adds the choices in the model instance
Jul 30, 2019
4c0e8de
Add test that domain of influences are coherent with ballot app
Jul 30, 2019
c7d75cc
Fixes date handling of form
Jul 31, 2019
fe2cf9b
Corrects info tooltip length
Jul 31, 2019
25a8ea5
Linting
Jul 31, 2019
fc46caa
Adds correct model filtering to the archive search for all params
Jul 31, 2019
6154969
Fixes order of entries to fail test
Jul 31, 2019
289aec8
Adds new Collection to __all__
Jul 31, 2019
30c3f37
Adds a method to reset params to default used for the query function
Jul 31, 2019
8244ac4
Adds answer options for vote and complex vote to be translated
Jul 31, 2019
8b4b2b0
Fixes choices of answers/results and the query to database
Jul 31, 2019
ac47fca
Linting
Jul 31, 2019
b247ff7
Adding first tests for archive search and collection
Jul 31, 2019
a212adc
Display results of archive search and count of results
Jul 31, 2019
fe57c21
Adds user info on how compound elections are displayed
Aug 1, 2019
6ad05bc
Adds locale and text search using locale to query function
Aug 1, 2019
d129789
Allows clear separation between two onegov modules
Aug 2, 2019
70862f4
Adds tests for query with voting result
Aug 5, 2019
befe153
Adds test for query using all query params
Aug 5, 2019
93350ec
Adds further tests
Aug 5, 2019
f4e7a4c
Fixes import of ArchiveSearchForm
Aug 5, 2019
3cf4309
Fixes implicit using of english for rumantsch to be explicit
Aug 5, 2019
5db43bb
Tests if title is queried in the correct language
Aug 5, 2019
ec618ab
Linting
Aug 5, 2019
c0fca64
Renames result to answer according to the vote model
Aug 5, 2019
58256f0
Adds simple test for url archive-search
Aug 5, 2019
df0c20f
Adds tests form archive search form
Aug 5, 2019
b95307a
Renames result to answer according to Vote model in ballot
Aug 6, 2019
40ee30f
Fixes variable name
Aug 6, 2019
b83a0a1
Adds link in archive macro for archive archive_search
Aug 6, 2019
54c15a1
Linting
Aug 6, 2019
7db2e26
Fixes bugs due to renaming of result to answer and type_ to types
Aug 6, 2019
30a6247
Fixes import error of missing column 'gewahlt' of majorz test data
Aug 6, 2019
7f86b76
Adds tabbed view to archive-search
Aug 6, 2019
b5fa241
Adds first test for grouping of archive query
Aug 6, 2019
61e4aef
Fixes archive link for archive view
Aug 6, 2019
53d807f
Adds item type to template
Aug 7, 2019
a36a052
Fixes renaming of type to types in Collection
Aug 7, 2019
a12585c
Overwrites group_items to archive search
Aug 7, 2019
1bb0483
Improves error message for frontend
Aug 7, 2019
70fedd7
Removes wrong code, original code was fine, but test data was broken
Aug 7, 2019
b7d9b02
Adds test for function to derive list_id from knr
Aug 7, 2019
f309546
Corrects and extends tests for searchable archive collection
Aug 7, 2019
50ca7bd
Improves test for searchable archive view
Aug 7, 2019
96b1826
Adds table for votes
Aug 7, 2019
e8b20e6
Adds method to retrieve for collection based on item_type query param
Aug 7, 2019
438aebb
Fixes election compound entry in archive page
Aug 7, 2019
f145901
Renders only vote and elections in tab menu
Aug 7, 2019
89180c0
Hides fields if render_kw['hidden'] is set to True
Aug 7, 2019
ef20f90
Moves domain Checkboxfield to the middle
Aug 7, 2019
f6c94b5
hides answer field if elections are chosen
Aug 7, 2019
5e9c26d
Treats compound_elections as election and vice versa
Aug 7, 2019
1841d52
Adds different sorting done in db for different principal domain
Aug 7, 2019
a131a09
Exludes votes that are belonging to a compound of elections
Aug 7, 2019
ab7b18e
Removes info tip since not needed fields will disappear based on domain
Aug 7, 2019
91ff799
Adds table to display archived elections
Aug 7, 2019
f298c26
Tests the output of the group_items function used in view/template
Aug 7, 2019
74ba5b5
Adds missing whitespace
Aug 7, 2019
2508965
Gets rid of shortcode, adds no wrap for th in tables
Aug 7, 2019
cd68526
Adds css and aligns table data vertically on top
Aug 7, 2019
8da5a7e
Linting
Aug 7, 2019
40f9061
Fixes not rendering fields if hidden kwarg is not set at all
Aug 7, 2019
6ab64e2
Improves error message returned if list_id not found in added_lists
Aug 7, 2019
3dd1b64
Comments failing test
Aug 9, 2019
def31cb
Remove unused statement
Aug 9, 2019
3dc4002
Adds pagination to archive search with tests
Aug 9, 2019
6bb8946
Linting
Aug 9, 2019
b7b7720
Update german translations
Aug 9, 2019
348e469
Adds translation field and translation in german
Aug 9, 2019
d59ff70
merges new release
Aug 9, 2019
9ce3c9c
Regenerates translations
Aug 9, 2019
61297a7
Linting
Aug 13, 2019
6cda589
Fixes un-applied translation strings
Aug 14, 2019
4a554ee
Adds german and french translations
Aug 14, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion onegov/election_day/collections/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand All @@ -14,6 +14,7 @@


__all__ = [
'SearchableArchivedResultCollection',
'ArchivedResultCollection',
'DataSourceCollection',
'DataSourceItemCollection',
Expand Down
235 changes: 231 additions & 4 deletions onegov/election_day/collections/archived_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since domain and answer are lists (i.e. contain a plural of elements), I'd name them domains and answers.

term=None,
answer=None,
locale='de_CH',
page=0
):
"""

:param session:
gh-PonyM marked this conversation as resolved.
Show resolved Hide resolved
: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 = [
gh-PonyM marked this conversation as resolved.
Show resolved Hide resolved
id_ for item in items for id_ in getattr(item, 'elections', [])
]

items = dict(
votes=[v for v in items if v.type == 'vote'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you don't intend to add any items to votes or elections, I would not use a list, instead use a tuple:

votes = tuple(v for v in items if v.type == 'vote')

Note that this is not the same as writing

votes = (v for v in items if v.type == 'vote')

Which would result in a generator, which is not what you want here. By using a tuple you use less memory and you communicate your intent of this list not changing later.

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()]
gh-PonyM marked this conversation as resolved.
Show resolved Hide resolved
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 [
gh-PonyM marked this conversation as resolved.
Show resolved Hide resolved
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]
gh-PonyM marked this conversation as resolved.
Show resolved Hide resolved
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'
2 changes: 2 additions & 0 deletions onegov/election_day/forms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
85 changes: 85 additions & 0 deletions onegov/election_day/forms/archive.py
Original file line number Diff line number Diff line change
@@ -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)
Loading