diff --git a/mo/util.py b/mo/util.py index a72c5bd6fdf34800d2c58501adec3d719c18aa32..10c2d63896d8f70cb31de877a5a4ced9458a4e7f 100644 --- a/mo/util.py +++ b/mo/util.py @@ -13,7 +13,7 @@ import re import secrets import subprocess import sys -from typing import Any, Optional, NoReturn, Tuple +from typing import Any, Optional, NoReturn, Tuple, List import textwrap import urllib.parse @@ -247,3 +247,24 @@ def check_points(points: decimal.Decimal, for_task: Optional[db.Task] = None, fo else: return f'Podle nastavení kola zadat body jen s krokem {points_step} (hodnota {points} je neplatná)' return None + + +def parse_int_list(a: str, maxim: int = 200) -> List[int]: + """Parsuje "1-3,5,7-9" na [1,2,3,5,7,9]. + Aby nešlo generovat moc velká pole (obrana proti DDoSu), + existuje omezení na velikost.""" + r: List[int] = [] + for i in a.split(","): + b = i.split("-") + if len(b) > 2: + raise mo.CheckError("Nadměrný počet pomlček") + try: + c = list(map(int, b)) + except ValueError: + raise mo.CheckError("Převod na číslo se nezdařil") + if any(x < 0 or x > maxim for x in c): + raise mo.CheckError("Překročen limit na velikost čísla") + if len(c) == 2 and c[0] > c[1]: + raise mo.CheckError("Větší číslo nemůže být před menším") + r += [c[0]] if len(c) == 1 else range(c[0], c[1] + 1) + return r diff --git a/mo/web/org_users.py b/mo/web/org_users.py index c29beed486be54a99e4d3b6c3ce72667168f608c..db44ea01379bbdde91ecfeb5d973cfb09d7975a9 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -1,9 +1,10 @@ -from typing import Optional +from typing import Optional, List from flask import render_template, g, redirect, url_for, flash, request from flask_wtf import FlaskForm import werkzeug.exceptions import wtforms from sqlalchemy import or_ +import flask_sqlalchemy from sqlalchemy.orm import joinedload, subqueryload from wtforms import validators @@ -147,50 +148,149 @@ def org_users(): can_add=rr.have_right(Right.add_users), ) - class OrgsFilterForm(PagerForm): # user search_name = wtforms.TextField("Jméno/příjmení", render_kw={'autofocus': True}) search_email = wtforms.TextField("E-mail") - # TODO: filtering by roles? + search_role = wtforms.SelectMultipleField('Role', choices=db.RoleType.choices(), coerce=db.RoleType.coerce, validators=[validators.Optional()]) + search_right_for_place_code = wtforms.StringField('Právo pro oblast', validators=[validators.Optional()]) + search_in_place_code = wtforms.StringField('V oblasti', validators=[validators.Optional()]) + search_place_level = wtforms.SelectMultipleField("Úroveň oblasti", choices=[(i.level, i.name) for i in db.place_levels], validators=[validators.Optional()], coerce=int) + search_year = wtforms.StringField('Ročník', validators=[validators.Optional()]) + search_category = wtforms.StringField("Kategorie", validators=[validators.Optional()]) + search_seq = wtforms.StringField("Kolo", validators=[validators.Optional()]) + submit = wtforms.SubmitField("Filtrovat") + show_role_filter = wtforms.SubmitField("Zobrazit filtrování dle rolí") + hide_role_filter = wtforms.SubmitField("Skrýt filtrování dle rolí") - # Výstupní hodnoty filtru, None při nepoužitém filtru, prázdná db hodnota při - # nepovedené filtraci (neexistující místo a podobně) + is_role_filter = wtforms.HiddenField(default="") # "" -> skrýt. "yes" -> zobrazit + + # Výstupní hodnoty filtru, None při nepoužitém filtru nebo špatné hodnotě (takové filtry ignorujeme) f_search_name: Optional[str] = None f_search_email: Optional[str] = None + f_search_role: Optional[List[db.RoleType]] = None + f_search_right_for_place: Optional[db.Place] = None + f_search_in_place: Optional[db.Place] = None + f_search_year: Optional[List[int]] = None + f_search_category: Optional[List[str]] = None + f_search_place_level: Optional[List[int]] = None + f_search_seq: Optional[List[int]] = None + + def validate_search_name(self, field): + self.f_search_name = f"%{field.data}%" + + def validate_search_email(self, field): + self.f_search_email = f"%{field.data}%" + + def validate_search_role(self, field): + self.f_search_role = field.data + + def validate_search_right_for_place_code(self, field): + self.f_search_right_for_place = db.get_place_by_code(field.data) + if self.f_search_right_for_place is None: + raise wtforms.ValidationError("Chybné označení oblasti") + + def validate_search_in_place_code(self, field): + self.f_search_in_place = db.get_place_by_code(field.data) + if self.f_search_in_place is None: + raise wtforms.ValidationError("Chybné označení oblasti") + + def validate_search_year(self, field): + try: + self.f_search_year = mo.util.parse_int_list(field.data) + except mo.CheckError as e: + raise wtforms.ValidationError(str(e)) - def validate(self): - self.f_search_name = f"%{self.search_name.data}%" if self.search_name.data else None - self.f_search_email = f"%{self.search_email.data}%" if self.search_email.data else None + def validate_search_category(self, field): + self.f_search_category = field.data.split(",") + def validate_search_place_level(self, field): + self.f_search_place_level = field.data -@app.route('/org/org/') + def validate_search_seq(self, field): + try: + self.f_search_seq = mo.util.parse_int_list(field.data) + except mo.CheckError as e: + raise wtforms.ValidationError(str(e)) + + def prepare_role_filter(self): + if self.show_role_filter.data: + self.is_role_filter.data = "yes" + if self.hide_role_filter.data: + self.is_role_filter.data = "" + if self.is_role_filter.data: + del self.show_role_filter + else: + del self.hide_role_filter + del self.search_role + del self.search_right_for_place_code + del self.search_in_place_code + del self.search_place_level + del self.search_year + del self.search_category + del self.search_seq + + +@app.route('/org/org/', methods=('GET', 'POST')) def org_orgs(): sess = db.get_session() rr = g.gatekeeper.rights_generic() - q = sess.query(db.User).filter(or_(db.User.is_admin, db.User.is_org)).options( subqueryload(db.User.roles).joinedload(db.UserRole.place) ) - filter = OrgsFilterForm(request.args) - filter.validate() + filter = OrgsFilterForm() + filter.validate_on_submit() + filter.prepare_role_filter() if filter.f_search_name: q = q.filter(or_( db.User.first_name.ilike(filter.f_search_name), db.User.last_name.ilike(filter.f_search_name) )) - if filter.f_search_email: - q = q.filter(db.User.email.ilike(filter.f_search_email)) + + def query_filter_role(qr: flask_sqlalchemy.BaseQuery) -> flask_sqlalchemy.BaseQuery: + if filter.f_search_role is not None: + qr = qr.filter(db.UserRole.role.in_(filter.f_search_role)) + if filter.f_search_category is not None: + qr = qr.filter(or_(db.UserRole.category.in_(filter.f_search_category), db.UserRole.category == None)) + if filter.f_search_seq is not None: + qr = qr.filter(or_(db.UserRole.seq.in_(filter.f_search_seq), db.UserRole.seq == None)) + if filter.f_search_year is not None: + qr = qr.filter(or_(db.UserRole.year.in_(filter.f_search_year), db.UserRole.year == None)) + if filter.f_search_in_place is not None: + qr = qr.filter(db.UserRole.place_id.in_(db.place_descendant_cte(filter.f_search_in_place))) + if filter.f_search_right_for_place is not None: + qr = qr.filter(db.UserRole.place_id.in_([x.place_id for x in db.get_place_parents(filter.f_search_right_for_place)])) + # Po n>3 hodinách v mo.db jsem dospěl k závěru, že to hezčeji neumím (neumím vyrobit place_parents_cte) + if filter.f_search_place_level is not None: + qr = qr.filter(db.UserRole.place_id.in_( + sess.query(db.Place.place_id).filter(db.Place.level.in_(filter.f_search_place_level)) + )) + return qr + + if filter.is_role_filter.data: + qr = sess.query(db.UserRole.user_id) + qr = query_filter_role(qr) + q = q.filter(db.User.user_id.in_(qr)) + + q = q.order_by(db.User.user_id) (count, q) = filter.apply_limits(q, pagesize=50) users = q.all() + marked_roles_id: Set[int] = set() + + if filter.is_role_filter.data: + qmr = sess.query(db.UserRole.user_role_id).filter(db.UserRole.user_id.in_([i.user_id for i in users])) + qmr = query_filter_role(qmr) + marked_roles_id = set([i[0] for i in qmr.all()]) + return render_template( 'org_orgs.html', users=users, count=count, filter=filter, + marked_roles_id=marked_roles_id, can_edit=rr.have_right(Right.edit_orgs), can_add=rr.have_right(Right.add_orgs), ) diff --git a/mo/web/templates/org_orgs.html b/mo/web/templates/org_orgs.html index dbd6cc7e52c2f7550237dd434fd46e35a19e1605..3d093c5389df2b03400c6a251f419fedc9beaa07 100644 --- a/mo/web/templates/org_orgs.html +++ b/mo/web/templates/org_orgs.html @@ -7,7 +7,9 @@ {% endif %} <div class="form-frame"> -<form action="" method="GET" role="form"> +<form action="" method="POST" role="form"> + {{ filter.csrf_token }} + {{ filter.is_role_filter }} <div class="row"> <div class='col-sm-2'><strong>Filtr organizátorů</strong></div> <div class="col-sm-3"> @@ -17,8 +19,35 @@ {{ wtf.form_field(filter.search_email, placeholder='Libovolná část e-mailu') }} </div> </div> + {% if filter.is_role_filter.data %} + <div class="row"> + <div class='col-sm-2'><strong>Filtr podle rolí</strong><p>Hledá pouze organizátory, kteří mají přidělenu nějakou roli.</p></div> + <div class="col-sm-2"> + {{ wtf.form_field(filter.search_role, size=filter.search_role.choices|length, class="form-control no-scroll") }} + </div> + <div class="col-sm-2"> + {{ wtf.form_field(filter.search_year, placeholder='Např. 65-67,70') }} + {{ wtf.form_field(filter.search_category, placeholder='Např. A,P,Z9') }} + </div> + <div class="col-sm-2"> + {{ wtf.form_field(filter.search_seq, placeholder='Např. 1,3-4') }} + </div> + <div class="col-sm-2"> + <span title="Omezí role na ty, které jsou přiděleny k dané oblasti a nebo její podoblasti.">{{ wtf.form_field(filter.search_in_place_code, placeholder='Kód oblasti') }}</span> + <span title="Omezí role na ty, které mají právo k dané oblasti. Tedy mohou být přiděleny i k nadřazené oblasti.">{{ wtf.form_field(filter.search_right_for_place_code, placeholder='Kód oblasti') }}</span> + </div> + <div class="col-sm-2"> + {{ wtf.form_field(filter.search_place_level, size=filter.search_place_level.choices|length, class="form-control no-scroll" ) }} + </div> + </div> + {% endif %} <div class="btn-group"> {{ wtf.form_field(filter.submit, class='btn btn-primary') }} + {% if filter.is_role_filter.data %} + {{ wtf.form_field(filter.hide_role_filter) }} + {% else %} + {{ wtf.form_field(filter.show_role_filter) }} + {% endif %} {% if filter.offset.data > 0 %} {{ wtf.form_field(filter.previous) }} {% else %} @@ -52,10 +81,15 @@ <tr> <td>{{ user.first_name }}</td><td>{{ user.last_name }}</td> <td>{{ user.email|mailto }}{{ user|user_flags }}</td> - <td>{% if user.is_admin %}správce{% elif user.roles|count == 0 %}<i>žádná role</i>{% else %} + <td>{% if user.is_admin %}správce{% elif user.roles|count == 0 %}<i>žádná role</i>{% endif %} + {% if user.roles|count > 0 %} <ul> {% for role in user.roles %} - <li>{{ role }}</li> + {% if role.user_role_id in marked_roles_id %} + <li><b>{{ role }}</b></li> + {% else %} + <li>{{ role }}</li> + {% endif %} {%- endfor %} </ul> {% endif %}</td> diff --git a/static/mo.css b/static/mo.css index 3c5df7742a44ae139d1ac48fd4c6886280bdfc0b..7dcf41627fcf49589e0437c14bdfd8833da9d03f 100644 --- a/static/mo.css +++ b/static/mo.css @@ -179,6 +179,10 @@ nav#main-menu a.active { border-radius: 4px 4px; } +select.no-scroll::-webkit-scrollbar { + display: none; +} + .checked_toggle input.toggle:checked ~ .checked_hide { display: none; }