diff --git a/mo/web/org.py b/mo/web/org.py index 1158d6ddd6ebdaac438269cc48d080a88d6f9ffe..e27f3587ade8e6c2e6a70b9587400f91585095be 100644 --- a/mo/web/org.py +++ b/mo/web/org.py @@ -215,5 +215,10 @@ def org_export_schools(): 'kraj_code': k.get_code(), }) - table = Table(school_export_columns, gen_rows(), 'skoly') + table = Table( + id="skoly", + columns=school_export_columns, + rows=gen_rows(), + filename='skoly', + ) return table.send_as(format, streaming=True) diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index 1bd3528163915fe5b5fa1100feeec9e827af48bd..fccf0413b99fd7aa864871a02549072b6452ec6a 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -551,13 +551,14 @@ def org_generic_list(round_id: Optional[int] = None, hier_id: Optional[int] = No filter=filter, count=count, action_form=action_form, ) else: - table = make_contestant_table(query, round, is_export=True) - return table.send_as(format) + table = make_contestant_table(query, round) + return table.send_as(format, args=request.args) contest_list_columns = ( Column(key='first_name', name='krestni', title='Křestní jméno'), Column(key='last_name', name='prijmeni', title='Příjmení'), + Column(key='user_id', name='user_id', title='ID uživatele', in_html=False), Column(key='email', name='email', title='E-mail'), Column(key='school', name='skola', title='Škola'), Column(key='school_code', name='kod_skoly', title='Kód školy'), @@ -608,7 +609,7 @@ def get_contestants_query( return query -def make_contestant_table(query: Query, round: db.Round, add_checkbox: bool = False, add_contest_column: bool = False, is_export: bool = False): +def make_contestant_table(query: Query, round: db.Round, add_checkbox: bool = False, add_contest_column: bool = False): ctants = query.all() rows: List[Row] = [] @@ -640,13 +641,12 @@ def make_contestant_table(query: Query, round: db.Round, add_checkbox: bool = Fa cols: List[Column] = list(contest_list_columns) if add_checkbox: - cols = [Column(key='checkbox', name=' ', title=' ')] + cols + cols = [Column(key='checkbox', name=' ', title=' ', in_export=None)] + cols if add_contest_column: cols.append(Column(key='region_code', name='kod_oblasti', title=round.get_level().name.title())) - if is_export: - cols.append(Column(key='user_id', name='user_id')) return Table( + id="ucastnici", columns=cols, rows=rows, filename='ucastnici', diff --git a/mo/web/org_score.py b/mo/web/org_score.py index 44642e51651f760e7a9da48d605f0d42c53253b3..4f6eafef0d818941bf189bd51bd0d52fb5788d6b 100644 --- a/mo/web/org_score.py +++ b/mo/web/org_score.py @@ -141,22 +141,22 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_ subcontest_id_map[(subcontest.round_id, subcontest.master_contest_id)] = subcontest.contest_id # Construct columns - is_export = (format != "") - columns = [] - columns.append(Column(key='order', name='poradi', title='Pořadí')) - if is_export: - columns.append(Column(key='status', name='stav')) - columns.append(Column(key='participant', name='ucastnik', title='Účastník')) - if is_export: - columns.append(Column(key='email', name='email')) + columns = [ + Column(key='order', name='poradi', title='Pořadí'), + Column(key='status', name='stav', title='Stav (vítěz, …)', in_html=False), + Column(key='participant', name='ucastnik', title='Účastník', in_export=False), + Column(key='first_name', name='krestni', title='Křestní jméno', in_html=False), + Column(key='last_name', name='prijmeni', title='Příjmení', in_html=False), + Column(key='email', name='email', title='E-mail', in_html=False), + ] if not ct_id: - columns.append(Column(key='contest', name='oblast', title=round.get_level().name.title())) - if is_export: - columns.append(Column(key='pion_place', name='soutezni_misto')) - columns.append(Column(key='school', name='skola', title='Škola')) - columns.append(Column(key='grade', name='rocnik', title='Ročník')) - if is_export: - columns.append(Column(key='birth_year', name='rok_narozeni')) + columns.append(Column(key='contest', name='oblast', title='Soutěžní ' + round.get_level().name)) + columns.extend([ + Column(key='pion_place', name='soutezni_misto', title='Soutěžní místo', in_html=False), + Column(key='school', name='skola', title='Škola'), + Column(key='grade', name='rocnik', title='Ročník'), + Column(key='birth_year', name='rok_narozeni', title='Rok narození', in_html=False), + ]) for task in tasks: title = task.code if contest: @@ -173,7 +173,7 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_ columns.append(Column(key='total_points', name='celkove_body', title='Celkové body')) if is_edit: columns.append(Column(key='suborder', name='zjednoznacneni_poradi', title='Zjednoznačnění')) - # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč')) + columns.append(Column(key='order_key', name='order_key', title='Třídící klíč', in_html=False, in_export=False)) # Construct rows table_rows = [] @@ -199,6 +199,8 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_ 'order': order_cell, 'status': status, 'user': user, + 'first_name': user.first_name, + 'last_name': user.last_name, 'email': user.email, 'participant': cell_pion_link(user, local_pion_ct_id, user.full_name()), 'contest': CellLink(pion.contest.place.name or "?", url_for('org_contest', ct_id=pion.contest_id)), @@ -228,6 +230,7 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_ if contest: filename += f"_oblast_{contest.place.code or contest.place.place_id}" table = Table( + id="vysledky", table_class="data full center", columns=columns, rows=table_rows, @@ -254,7 +257,7 @@ def org_score(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_ edit_form=edit_form, snapshot_form=snapshot_form, ) else: - return table.send_as(format) + return table.send_as(format, args=request.args) class SetFinalScoretableForm(FlaskForm): @@ -401,11 +404,12 @@ def org_score_snapshot(ct_id: int, scoretable_id: int): if not scoretable or scoretable.contest_id != ct_id: raise werkzeug.exceptions.NotFound() - columns, table_rows = scoretable_construct(scoretable, format != "") + columns, table_rows = scoretable_construct(scoretable) # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč')) filename = f"vysledky_{ctx.round.year}-{ctx.round.category}-{ctx.round.level}_oblast_{ctx.contest.place.code or ctx.contest.place.place_id}" table = Table( + id="vysledky", table_class="data full center", columns=columns, rows=table_rows, diff --git a/mo/web/table.py b/mo/web/table.py index f95571a34282f974d6f807535a59c97e55b69e9f..65fdde48fd4f6f65e15967102760dd351c0283ff 100644 --- a/mo/web/table.py +++ b/mo/web/table.py @@ -4,8 +4,9 @@ from flask import Response, url_for from html import escape import io from markupsafe import Markup -from typing import Any, Dict, Sequence, Optional, Iterable, Union +from typing import Any, Dict, List, Sequence, Optional, Iterable, Union import urllib.parse +from werkzeug.datastructures import ImmutableMultiDict import werkzeug.exceptions from mo.csv import FileFormat @@ -19,11 +20,15 @@ class Column: key: str # Jméno klíče ve slovnících name: str # Název v hlavičce CSV title: str # Titulek v HTML tabulkách + in_html: bool # Jestli sloupec zobrazit na webu + in_export: Optional[bool] # Jestli sloupec defaultně exportovat, None = ani nenabízet - def __init__(self, key: str, name: Optional[str] = None, title: Optional[str] = None): + def __init__(self, key: str, name: Optional[str] = None, title: Optional[str] = None, in_html: bool = True, in_export: Optional[bool] = True): self.key = key self.name = name or key self.title = title or self.name + self.in_html = in_html + self.in_export = in_export class Cell: @@ -142,6 +147,7 @@ class OrderCell(Cell): class Table: + id: str # validní jako HTML id a unikátní pro tabulky zobrazené na stejné stránce columns: Sequence[Column] rows: Iterable[Row] filename: str @@ -149,10 +155,11 @@ class Table: table_class: str def __init__( - self, columns: Sequence[Column], rows: Iterable[Row], + self, id: str, columns: Sequence[Column], rows: Iterable[Row], filename: str, show_downlink: bool = True, table_class: str = "data" ): + self.id = id self.columns = columns self.rows = rows self.filename = filename @@ -160,9 +167,11 @@ class Table: self.table_class = table_class def to_html(self) -> str: - tab = [f'<table class="{self.table_class}">', '<thead>', '<tr>'] + tab = [f'<table class="{self.table_class}" id="{self.id}">', '<thead>', '<tr>'] for c in self.columns: + if not c.in_html: + continue tab.append(f'\t<th>{c.title}') tab.append('<tbody>') @@ -171,6 +180,8 @@ class Table: r_attr = [f' {key}="{val}"' for (key, val) in r.html_attr.items()] tab.append(f'<tr{"".join(r_attr)}>') for c in self.columns: + if not c.in_html: + continue val = r.get(c.key) if isinstance(val, Cell): tab.append(val.to_html()) @@ -179,32 +190,56 @@ class Table: tab.append('</table>') if self.show_downlink: - tab.append("<p>Stáhnout jako <a href='?format=en_csv'>CSV s čárkami</a>, <a href='?format=cs_csv'>CSV se středníky</a> nebo <a href='?format=tsv'>TSV</a>.") + id = 'column-selection-toggle-' + self.id + tab.extend([ + '<form method="GET">', + ' <p>Stáhnout jako <button class="btn btn-default btn-xs" name="format" value="en_csv">CSV s čárkami</button>,', + ' <button class="btn btn-default btn-xs" name="format" value="cs_csv">CSV se středníky</button> nebo <button class="btn btn-default btn-xs" name="format" value="tsv">TSV</button>.', + f' <div class="collapsible"><input type="checkbox" class="toggle" id="{id}">', + f' <label for="{id}" class="toggle toggle-small">Vybrat sloupce pro stažení</label>', + ' <div class="collapsible-inner"><div class="form-frame">', + self.get_columns_checkboxes(line_prefix=" "), + ' </div></div>', + ' </div>', + '</form>', + ]) + return Markup("\n".join(tab)) - def to_csv(self, fmt: FileFormat) -> bytes: + def get_columns_checkboxes(self, line_prefix: str = "", args: Optional[ImmutableMultiDict] = None) -> Markup: + out = [line_prefix + '<input type="hidden" name="do_column_selection" value="1">'] + for c in self.columns: + if c.in_export is None: + continue + checked = c.in_export + if args is not None and args.get('do_column_selection') is not None: + checked = args.get('column_' + c.name) is not None + out.append(f'{line_prefix}<div class="checkbox show"><label><input name="column_{c.name}" type="checkbox"{" checked" * checked} value="1">{c.title} <small>(<code>{c.name}</code></small>)</label></div>') + return Markup("\n".join(out)) + + def to_csv(self, fmt: FileFormat, export_columns: List[Column]) -> bytes: out = io.StringIO() writer = csv.writer(out, dialect=fmt.get_dialect()) - header = [c.name for c in self.columns] + header = [c.name for c in export_columns] writer.writerow(header) for row in self.rows: - r = [row.get(c.key) for c in self.columns] + r = [row.get(c.key) for c in export_columns] writer.writerow(r) return out.getvalue().encode(fmt.get_charset()) - def to_csv_stream(self, fmt: FileFormat) -> Iterable[bytes]: + def to_csv_stream(self, fmt: FileFormat, export_columns: List[Column]) -> Iterable[bytes]: out = io.StringIO() writer = csv.writer(out, dialect=fmt.get_dialect()) - header = [c.name for c in self.columns] + header = [c.name for c in export_columns] writer.writerow(header) nrows = 0 for row in self.rows: - r = [row.get(c.key) for c in self.columns] + r = [row.get(c.key) for c in export_columns] writer.writerow(r) nrows += 1 @@ -216,16 +251,23 @@ class Table: yield out.getvalue().encode(fmt.get_charset()) - def send_as(self, format: Union[FileFormat, str], streaming: bool = False) -> Response: + def send_as(self, format: Union[FileFormat, str], streaming: bool = False, args: Optional[ImmutableMultiDict] = None) -> Response: try: fmt = FileFormat.coerce(format) except ValueError: raise werkzeug.exceptions.NotFound() + export_columns = [c for c in self.columns if c.in_export] + if args is not None and args.get('do_column_selection') is not None: + export_columns = [c for c in self.columns if args.get('column_' + c.name) is not None] + + if len(export_columns) == 0: + raise werkzeug.exceptions.BadRequest("Žádné sloupce v exportu, musíte zvolit alespoň jeden") + if streaming: - resp = Response(self.to_csv_stream(fmt)) + resp = Response(self.to_csv_stream(fmt, export_columns)) else: - out = self.to_csv(fmt) + out = self.to_csv(fmt, export_columns) resp = app.make_response(out) resp.content_type = fmt.get_content_type() diff --git a/mo/web/templates/org_generic_list.html b/mo/web/templates/org_generic_list.html index 379c7a3ab1cafc5b8cb435f30ae37be0e4b20c17..40785f472d1df04143e32c575ef3b9830901b8af 100644 --- a/mo/web/templates/org_generic_list.html +++ b/mo/web/templates/org_generic_list.html @@ -63,6 +63,14 @@ Celkem <b>{{count|inflected('nalezený účastník', 'nalezení účastníci', 'nalezených účastníků')}}</b>. {% endif %} </div> + <div class="form-row"> + <div class="collapsible"><input type="checkbox" class="toggle" id="column-selection-toggle"> + <label for="column-selection-toggle" class="toggle toggle-small">Vybrat sloupce pro stažení</label> + <div class="collapsible-inner"><div class="form-frame"> + {{ table.get_columns_checkboxes(line_prefix="\t\t\t\t", args=request.args) }} + </div></div> + </div> + </div> </form> </div> diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html index a725ddb86d5913f35ae49f8be059e8ea7b66d0e6..177920c85e58ffb05d5144894847f811c9837e79 100644 --- a/mo/web/templates/org_score.html +++ b/mo/web/templates/org_score.html @@ -38,7 +38,7 @@ {% elif type == "warning" %}<li>Varování: {{ msg }} {% else %}<li class="text-info">Info: {{ msg }}{% endif %} {% endfor %} - </ul></div> + </ul> </div> </div> </div> diff --git a/mo/web/user.py b/mo/web/user.py index 93a21b40311aeee80e6907b26473885636835e00..fd56284a3f85f96a205bf1861a2a5d7ee25214fd 100644 --- a/mo/web/user.py +++ b/mo/web/user.py @@ -495,18 +495,17 @@ def user_paper(contest_id: int, paper_id: int): return mo.web.util.send_task_paper(paper) -def scoretable_construct(scoretable: db.ScoreTable, is_export: bool = False) -> Tuple[List[Column], List[Row]]: +def scoretable_construct(scoretable: db.ScoreTable) -> Tuple[List[Column], List[Row]]: """Pro konstrukci výsledkovky zobrazované soutěžícím. Využito i při zobrazení uložených snapshotů výsledkovky v org_score.py. """ columns = [ - Column(key='order', name='poradi', title='Pořadí'), - Column(key='name', name='ucastnik', title='Účastník'), - Column(key='school', name='skola', title='Škola'), - Column(key='grade', name='rocnik', title='Ročník') + Column(key='order', name='poradi', title='Pořadí'), + Column(key='status', name='stav', title='Stav účasti', in_html=False), + Column(key='name', name='ucastnik', title='Účastník'), + Column(key='school', name='skola', title='Škola'), + Column(key='grade', name='rocnik', title='Ročník'), ] - if is_export: - columns.insert(1, Column(key='status', name='stav')) for (code, name) in scoretable.tasks: columns.append(Column(key=f'task_{code}', name=code, title=code)) @@ -544,11 +543,12 @@ def user_contest_score(id: int): if not contest.scoretable or state not in [db.RoundState.graded, db.RoundState.closed]: raise werkzeug.exceptions.NotFound() - columns, table_rows = scoretable_construct(contest.scoretable, format != "") + columns, table_rows = scoretable_construct(contest.scoretable) # columns.append(Column(key='order_key', name='order_key', title='Třídící klíč')) filename = f"vysledky_{round.year}-{round.category}-{round.level}_oblast_{contest.place.code or contest.place.place_id}" table = Table( + id="vysledky", table_class="data full center", columns=columns, rows=table_rows, diff --git a/static/mo.css b/static/mo.css index 7fb589d20d9b0db4a5d179538c5f94b98e08c51e..5f368cdca37000dc01c85f70f35f5350f2c540ab 100644 --- a/static/mo.css +++ b/static/mo.css @@ -388,6 +388,10 @@ div.alert + div.alert { cursor: pointer; margin-left: 15px; } +.collapsible label.toggle-small { + font-weight: normal; + margin-bottom: 0px; +} .collapsible label.toggle::before { position: absolute; content: "";