diff --git a/bin/create-round b/bin/create-round
index a65a32d732791b61055cf4501a020756b48fb95b..762b37d547c9a9164c7f41e050b30f8c97611d43 100755
--- a/bin/create-round
+++ b/bin/create-round
@@ -10,6 +10,7 @@ parser = argparse.ArgumentParser(description='Založí soutěžní kolo')
parser.add_argument('-y', '--year', type=int, required=True, help='ročník')
parser.add_argument('-c', '--cat', type=str, required=True, help='kategorie')
parser.add_argument('-s', '--seq', type=int, required=True, help='pořadí kola')
+parser.add_argument('-C', '--code', type=str, help='kód kola (default: roven pořadí)')
parser.add_argument('-l', '--level', type=int, required=True, help='úroveň v hierarchii oblastí')
parser.add_argument('-p', '--part', type=int, default=0, help='část v rámci skupiny kol (default: 0)')
parser.add_argument('-n', '--name', type=str, required=True, help='název kola')
@@ -33,6 +34,7 @@ rnd = db.Round(
year=args.year,
category=args.cat,
seq=args.seq,
+ code=args.code or str(args.seq),
part=args.part,
level=args.level,
name=args.name,
diff --git a/bin/import-points b/bin/import-points
index 17055911b7a7e5f2d95f4ca0c481a67b98b3d62e..14227e981031282456cd5d01b6de61c81b574ac9 100755
--- a/bin/import-points
+++ b/bin/import-points
@@ -5,7 +5,7 @@ import sys
from mo.csv import FileFormat
import mo.db as db
-from mo.imports import create_import, ImportType
+from mo.imports import PointsImport
import mo.users
import mo.util
from mo.util import die
@@ -37,14 +37,13 @@ user = mo.users.user_by_email(args.user)
if user is None:
die(f"Uživatel {args.user} neexistuje")
-imp = create_import(
+imp = PointsImport(
user,
- type=ImportType.points,
- fmt=FileFormat.tsv,
round=round,
task=task,
allow_add_del=args.add_del,
)
+imp.fmt = FileFormat.tsv
if args.import_file:
if not imp.run(args.import_file):
diff --git a/db/db.ddl b/db/db.ddl
index 1ffb18fa577c3342598437c3e2b2b7eeff4747a1..f08596aa1ff776cde48dd17b9160a1e1e20db801 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -113,10 +113,10 @@ CREATE TABLE rounds (
master_round_id int DEFAULT NULL REFERENCES rounds(round_id),
year int NOT NULL, -- ročník MO
category varchar(2) NOT NULL, -- "A", "Z5" apod.
- seq int NOT NULL, -- 1=domácí kolo atd.
+ seq int NOT NULL, -- pořadí kola v kategorii (od 1)
part int NOT NULL DEFAULT 0, -- část kola (nenulová u dělených kol)
level int NOT NULL, -- úroveň hierarchie míst
- code varchar(255) NOT NULL DEFAULT '', -- kód kola ("1", "S" apod.), prázdný=podle seq
+ code varchar(255) NOT NULL, -- kód kola ("1", "S" apod.)
name varchar(255) NOT NULL, -- zobrazované jméno ("Krajské kolo" apod.)
state round_state NOT NULL DEFAULT 'preparing', -- stav kola
tasks_file varchar(255) DEFAULT NULL, -- jméno souboru se zadáním úloh
@@ -131,7 +131,8 @@ CREATE TABLE rounds (
has_messages boolean NOT NULL DEFAULT false, -- má zprávičky
enroll_mode enroll_mode NOT NULL DEFAULT 'manual', -- režim přihlašování (pro vyšší kola vždy 'manual')
enroll_advert varchar(255) NOT NULL DEFAULT '', -- popis v přihlašovacím formuláři
- UNIQUE (year, category, seq, part)
+ UNIQUE (year, category, seq, part),
+ UNIQUE (year, category, code, part)
);
CREATE INDEX rounds_master_round_id_index ON rounds (master_round_id);
diff --git a/db/upgrade-20211006.sql b/db/upgrade-20211006.sql
new file mode 100644
index 0000000000000000000000000000000000000000..3e2dcd65c35ce923493265f34f90c5f361e26eb3
--- /dev/null
+++ b/db/upgrade-20211006.sql
@@ -0,0 +1,7 @@
+SET ROLE mo_osmo;
+
+ALTER TABLE rounds ALTER COLUMN code DROP DEFAULT;
+
+UPDATE rounds SET code=seq WHERE code='';
+
+ALTER TABLE rounds ADD UNIQUE(year, category, code, part);
diff --git a/mo/db.py b/mo/db.py
index 1b3e5ff9fe2c8d728718de6f5b3e7f7ae40fbf06..674be18e85bbec96b69f033baf852afea8c66964 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -9,7 +9,7 @@ import re
from sqlalchemy import \
Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, \
text, func, \
- create_engine, inspect, select
+ create_engine, inspect, select, or_, and_
from sqlalchemy.engine import Engine
from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload, aliased
from sqlalchemy.orm.attributes import get_history
@@ -136,7 +136,8 @@ class Place(Base):
return place_levels[self.level].in_name() + " " + name
-def get_root_place():
+def get_root_place() -> Place:
+ """Obvykle voláme mo.rights.Gatekeeper.get_root_place(), kterékešuje."""
return get_session().query(Place).filter_by(parent=None).one()
@@ -249,7 +250,7 @@ class Round(Base):
seq = Column(Integer, nullable=False)
part = Column(Integer, nullable=False)
level = Column(Integer, nullable=False)
- code = Column(String(255), nullable=False, server_default=text("''::text"))
+ code = Column(String(255), nullable=False)
name = Column(String(255), nullable=False)
state = Column(Enum(RoundState, name='round_state'), nullable=False, server_default=text("'preparing'::round_state"))
tasks_file = Column(String(255))
@@ -269,7 +270,7 @@ class Round(Base):
def round_code_short(self):
""" Pro samostatné kolo ekvivalentní s `round_code()`, pro skupinu kol společná část kódu. """
- return f"{self.year}-{self.category}-{self.code or self.seq}"
+ return f"{self.year}-{self.category}-{self.code}"
def part_code(self):
return chr(ord('a') + self.part - 1) if self.part > 0 else ""
@@ -317,6 +318,25 @@ class Round(Base):
return " ".join(times)
+def find_master_round(year: Optional[int], category: Optional[str], code: str) -> Round:
+ if not year:
+ raise mo.CheckError('Neuveden ročník pro nalezení kola')
+ if not category:
+ raise mo.CheckError('Neuvedena kategorie pro nalezení kola')
+
+ r = (
+ get_session().query(Round)
+ .filter_by(year=year, category=category, code=code)
+ .filter(Round.master_round_id == Round.round_id)
+ .all()
+ )
+ if len(r) < 1:
+ raise mo.CheckError(f'Kolo {year}-{category}-{code} nenalezeno')
+ if len(r) > 1:
+ raise mo.CheckError(f'Kolo {year}-{category}-{code} nelze určit jednoznačně')
+ return r[0]
+
+
class User(Base):
__tablename__ = 'users'
@@ -575,10 +595,10 @@ class UserRole(Base):
# Některé role mají omezení na úroveň hierarchie.
level = self.place.level if self.place else -1
rt = self.role
- if not (rt == RoleType.garant and level <= 0
- or rt == RoleType.garant_kraj and level == 1
- or rt == RoleType.garant_okres and level == 2
- or rt == RoleType.garant_skola and level >= 3):
+ if (rt == RoleType.garant and not level <= 0
+ or rt == RoleType.garant_kraj and not level == 1
+ or rt == RoleType.garant_okres and not level == 2
+ or rt == RoleType.garant_skola and not level >= 3):
return False
return True
diff --git a/mo/imports.py b/mo/imports.py
index 8e894710bdca8d88b1a5d845b368d1d40a2c33e0..05f7508c5088f27e537f99b41781820b304bade0 100644
--- a/mo/imports.py
+++ b/mo/imports.py
@@ -5,9 +5,8 @@ import io
import re
from sqlalchemy import and_
from sqlalchemy.orm import joinedload, Query
-from typing import List, Optional, Any, Dict, Type, Union, Set
+from typing import List, Optional, Any, Dict, Type, Union
-import mo.config as config
import mo.csv
from mo.csv import FileFormat, MissingHeaderError
import mo.db as db
@@ -19,24 +18,6 @@ from mo.util import logger
from mo.util_format import format_decimal
-class ImportType(db.MOEnum):
- participants = auto()
- proctors = auto()
- judges = auto()
- points = auto()
-
- def friendly_name(self) -> str:
- return import_type_names[self]
-
-
-import_type_names = {
- ImportType.participants.name: 'účastníci',
- ImportType.proctors.name: 'dozor',
- ImportType.judges.name: 'opravovatelé',
- ImportType.points.name: 'body',
-}
-
-
class Import:
# Výsledek importu
errors: List[str]
@@ -49,22 +30,25 @@ class Import:
cnt_set_points: int = 0
cnt_add_sols: int = 0
cnt_del_sols: int = 0
+ cnt_change_user_to_org: int = 0 # pro Import orgů: Počet provedených/požadovaných změn účastnka na orga
# Veřejné vlastnosti importu
template_basename: str = "sablona"
+ fmt: FileFormat
# Interní: Co a jak zrovna importujeme
user: db.User
- round: Optional[db.Round]
- contest: Optional[db.Contest]
- only_region: Optional[db.Place]
- task: Optional[db.Task] # pro Import bodů
- allow_add_del: bool # pro Import bodů: je povoleno zakládat/mazat řešení
- fmt: FileFormat
+ round: Optional[db.Round] = None
+ contest: Optional[db.Contest] = None
+ only_region: Optional[db.Place] = None
+ task: Optional[db.Task] = None # pro Import bodů
+ default_place: Optional[db.Place] = None
+ category: Optional[str] = None
row_class: Type[mo.csv.Row]
row_example: mo.csv.Row
log_msg_prefix: str
log_details: Any
+ allow_change_user_to_org: bool = False # pro Import orgů: je povoleno vyrobit orga z účastníka
# Interní: Stav importu
place_cache: Dict[str, db.Place]
@@ -74,17 +58,14 @@ class Import:
line_number: int = 0
row_name: Optional[str] = None
- def __init__(self):
+ def __init__(self, user: db.User):
self.errors = []
self.warnings = []
- self.rr = None
self.place_cache = {}
self.school_place_cache = {}
self.new_user_ids = []
-
- def setup(self):
- # Definováno odvozenými třídami
- assert NotImplementedError()
+ self.gatekeeper = mo.rights.Gatekeeper(user)
+ self.user = user
def error(self, msg: str) -> Any:
if self.line_number > 0:
@@ -127,9 +108,7 @@ class Import:
return name
- def check_rights(self, place: db.Place) -> bool:
- round = self.round
- assert round is not None
+ def check_rights(self, round: db.Round, place: db.Place) -> bool:
rights = self.gatekeeper.rights_for(place, round.year, round.category, round.seq)
return rights.have_right(mo.rights.Right.manage_contest)
@@ -142,15 +121,18 @@ class Import:
place = db.get_place_by_code(kod)
if not place:
- return self.error(f'{what.title()} s kódem "{kod}" neexistuje'+
- ('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
-
- if not self.check_rights(place):
- return self.error(f'Nemáte práva na správu soutěže {place.name_locative()}')
+ return self.error(f'{what.title()} s kódem "{kod}" neexistuje' +
+ ('. Nechybí vám # na začátku?' if re.fullmatch(r'\d+', kod) else ''))
self.place_cache[kod] = place
return place
+ def parse_role(self, name: str) -> Optional[db.RoleType]:
+ if name not in db.RoleType.__members__:
+ return self.error(f"Role {name} neexistuje. Podívejte se do manuálu na existující role.")
+
+ return db.RoleType[name]
+
def parse_school(self, kod: str) -> Optional[db.Place]:
if kod in self.school_place_cache:
return self.school_place_cache[kod]
@@ -190,9 +172,29 @@ class Import:
return r
+ def parse_category(self, kategorie: Optional[str]) -> Optional[str]:
+ if not kategorie:
+ return None
+ kategorie = kategorie.upper()
+ if not (kategorie[0].isalpha() and kategorie.isalnum() and len(kategorie) <= 2):
+ return self.error(f"Zadána chybná kategorie {kategorie}")
+ return kategorie
+
+ def parse_master_round(self, year: Optional[int], category: Optional[str], code: str) -> Optional[db.Round]:
+ try:
+ return mo.db.find_master_round(year, category, code)
+ except mo.CheckError as e:
+ return self.error(str(e))
+
+
def find_or_create_user(self, email: str, krestni: Optional[str], prijmeni: Optional[str], is_org: bool) -> Optional[db.User]:
try:
- user, is_new = mo.users.find_or_create_user(email, krestni, prijmeni, is_org, reason='import')
+ try:
+ user, is_new, is_user_to_org = mo.users.find_or_create_user(email, krestni, prijmeni, is_org, allow_change_user_to_org=self.allow_change_user_to_org, reason='import')
+ self.cnt_change_user_to_org += is_user_to_org
+ except mo.users.CheckErrorOrgIsUser as e:
+ self.cnt_change_user_to_org += 1
+ raise mo.CheckError(str(e) + " Změnu můžete povolit ve formuláři.")
except mo.CheckError as e:
return self.error(str(e))
if is_new:
@@ -239,8 +241,8 @@ class Import:
return False
return True
- def obtain_contest(self, oblast: Optional[db.Place], allow_none: bool = False):
- assert self.round
+ def obtain_contest(self, round: db.Round, oblast: Optional[db.Place], allow_none: bool = False) -> Optional[db.Contest]:
+ contest: Optional[db.Contest]
if oblast is not None and not self.place_is_allowed(oblast):
return self.error('Oblast neodpovídá té, do které se importuje')
if self.contest:
@@ -253,29 +255,45 @@ class Import:
if not allow_none:
self.error('Je nutné uvést kód oblasti')
return None
- contest = db.get_session().query(db.Contest).filter_by(round=self.round, place=oblast).one_or_none()
+ contest = db.get_session().query(db.Contest).filter_by(round=round, place=oblast).one_or_none()
if contest is None:
return self.error('V uvedené oblasti toto kolo neprobíhá')
return contest
- def add_role(self, user: db.User, place: db.Place, role: db.RoleType):
+ def add_role(self, user: db.User, role: db.RoleType, place: Optional[db.Place], year: Optional[int], category: Optional[str], round: Optional[db.Round]):
sess = db.get_session()
- round = self.round
- assert round is not None
+
+ if year and round and round.year != year:
+ return self.error('Ročník neodpovídá zadanému kolu.')
+ if category and round and round.category != category:
+ return self.error('Kategorie neodpovídá zadanému kolu.')
+ seq = None
+ if round:
+ category = round.category
+ year = round.year
+ seq = round.seq
+
if (sess.query(db.UserRole)
.filter_by(user=user, place=place, role=role,
- category=round.category, year=round.year, seq=round.seq)
+ category=category, year=year, seq=seq)
.with_for_update()
.first()):
pass
else:
ur = db.UserRole(user=user, place=place, role=role,
- category=round.category, year=round.year, seq=round.seq,
+ category=category, year=year, seq=seq,
assigned_by_user=self.user)
+
+ if not (ur.is_legal()):
+ return self.error('Tato kombinace role a místa není povolena')
+
+ if not (self.gatekeeper.can_set_role(ur)):
+ return self.error(f'Roli "{ur}" nelze přidělit, není podmnožinou žádné vaší role')
+
sess.add(ur)
sess.flush()
- logger.info(f'Import: {role.name.title()} user=#{user.user_id} place=#{place.place_id} user_role=#{ur.user_role_id}')
+ logger.info(f'Import: {role.name.title()} user=#{user.user_id} place=#{ place.place_id if place else "null" } user_role=#{ur.user_role_id}')
mo.util.log(
type=db.LogType.user_role,
what=ur.user_role_id,
@@ -283,11 +301,11 @@ class Import:
)
self.cnt_new_roles += 1
- def import_row(self, r: mo.csv.Row):
+ def import_row(self, r: mo.csv.Row) -> None:
# Definováno odvozenými třídami
assert NotImplementedError()
- def log_start(self, path):
+ def log_start(self, path) -> None:
args = [f'user=#{self.user.user_id}', f'fmt={self.fmt.name}']
if self.round is not None:
args.append(f'round=#{self.round.round_id}')
@@ -300,7 +318,7 @@ class Import:
logger.info('Import: %s ze souboru %s: %s', self.log_msg_prefix, path, " ".join(args))
- def log_end(self):
+ def log_end(self) -> None:
args = [f'rows=#{self.cnt_rows}']
for key, val in [
('users', self.cnt_new_users),
@@ -332,7 +350,8 @@ class Import:
details=details,
)
else:
- assert False
+ pass
+ # TODO
def check_utf8(self, path: str) -> bool:
# Není pěkné, že soubory čteme dvakrát, ale ve srovnání s pasekou, kterou by
@@ -347,8 +366,8 @@ class Import:
def get_row_name(self, row: mo.csv.Row) -> Optional[str]:
if hasattr(row, 'email'):
- return row.email # type: ignore
- # čtení prvku potomka
+ # čtení prvku potomka
+ return row.email # type: ignore
return None
def generic_import(self, path: str) -> bool:
@@ -382,7 +401,7 @@ class Import:
return len(self.errors) == 0
- def notify_users(self):
+ def notify_users(self) -> None:
# Projde všechny uživatele a těm, kteří ještě nemají nastavené heslo,
# ani nepožádali o jeho reset, pošle mail s odkazem na reset. Každého
# uživatele zpracováváme ve zvlášť transakci, aby se po chybě neztratila
@@ -417,6 +436,9 @@ class Import:
self.notify_users()
return True
+ def get_after_import_message(self) -> str:
+ return "Import proveden."
+
@dataclass
class ContestImportRow(mo.csv.Row):
@@ -444,11 +466,24 @@ class ContestImport(Import):
log_details = {'action': 'import'}
template_basename = 'sablona-ucast'
- def setup(self):
- assert self.round is not None
+ def __init__(
+ self,
+ user: db.User,
+ round: db.Round,
+ contest: Optional[db.Contest] = None,
+ only_region: Optional[db.Place] = None,
+ default_place: Optional[db.Place] = None
+ ):
+ super().__init__(user)
+ self.user = user
+ self.round = round
+ self.contest = contest
+ self.only_region = only_region
+ self.default_place = default_place
assert not self.round.is_subround()
- def import_row(self, r: mo.csv.Row):
+ def import_row(self, r: mo.csv.Row) -> None:
+ assert self.round
assert isinstance(r, ContestImportRow)
num_prev_errs = len(self.errors)
email = self.parse_email(r.email)
@@ -458,7 +493,12 @@ class ContestImport(Import):
rocnik = self.parse_grade(r.rocnik, (school_place.school if school_place else None)) if r.rocnik else None
rok_naroz = self.parse_born(r.rok_naroz) if r.rok_naroz else None
misto = self.parse_opt_place(r.kod_mista, 'místo')
+ if misto and not self.check_rights(self.round, misto):
+ return self.error(f'Nemáte práva na správu soutěže {misto.name_locative()}')
+
oblast = self.parse_opt_place(r.kod_oblasti, 'oblast')
+ if oblast and not self.check_rights(self.round, oblast):
+ return self.error(f'Nemáte práva na správu soutěže {oblast.name_locative()}')
if (len(self.errors) > num_prev_errs
or email is None):
@@ -468,113 +508,130 @@ class ContestImport(Import):
if user is None:
return
- part = self.find_or_create_participant(user, config.CURRENT_YEAR, school_place.place_id if school_place else None, rok_naroz, rocnik)
+ part = self.find_or_create_participant(user, self.round.year, school_place.place_id if school_place else None, rok_naroz, rocnik)
if part is None:
return
- contest = self.obtain_contest(oblast)
+ contest = self.obtain_contest(self.round, oblast)
if contest is None:
return
self.find_or_create_participation(user, contest, misto)
+ def get_after_import_message(self) -> str:
+ return f'Importováno ({self.cnt_rows} řádků, založeno {self.cnt_new_users} uživatelů, {self.cnt_new_participations} účastí, {self.cnt_new_roles} rolí)'
+
@dataclass
-class ProctorImportRow(mo.csv.Row):
+class OrgsImportRow(mo.csv.Row):
email: str = ""
krestni: str = ""
prijmeni: str = ""
- kod_mista: str = ""
+ kod_oblasti: str = ""
+ role: str = ""
-class ProctorImport(Import):
- row_class = ProctorImportRow
- row_example = ProctorImportRow(
+class OrgsImport(Import):
+ row_class = OrgsImportRow
+ row_example = OrgsImportRow(
email='nekdo@example.org',
krestni='Pokusný',
prijmeni='Králík',
- kod_mista='#3333',
+ kod_oblasti='#3333',
+ role='dozor',
)
- log_msg_prefix = 'Dozor'
- log_details = {'action': 'import-proctors'}
- template_basename = 'sablona-dozor'
-
- def setup(self):
- assert self.round is not None
+ log_msg_prefix = 'Organizátoři'
+ log_details = {'action': 'import-orgs'}
+ template_basename = 'sablona-organizatori'
+
+ default_cat: Optional[str]
+ default_code: Optional[str]
+
+ def __init__(
+ self,
+ user: db.User,
+ round: Optional[db.Round] = None, # Prázdné může být pouze když se jedná o GlobalOrgsImport
+ contest: Optional[db.Contest] = None,
+ only_region: Optional[db.Place] = None,
+ allow_change_user_to_org: bool = False,
+ default_place: Optional[db.Place] = None,
+ default_cat: Optional[str] = None,
+ default_code: Optional[str] = None,
+ year: Optional[int] = None
+ ):
+ super().__init__(user)
+ self.round = round
+ self.contest = contest
+ self.only_region = only_region
+ self.default_place = default_place
+ self.default_cat = self.parse_category(default_cat)
+ self.default_code = default_code
+ self.default_place = default_place
+ self.allow_change_user_to_org = allow_change_user_to_org
+ self.root_place = db.get_root_place()
+ self.year = round.year if round else year
+ assert hasattr(self.row_class, "kolo") or self.round
- def import_row(self, r: mo.csv.Row):
- assert isinstance(r, ProctorImportRow)
+ def import_row(self, r: mo.csv.Row) -> None:
+ assert isinstance(r, OrgsImportRow) or isinstance(r, GlobalOrgsImportRow)
num_prev_errs = len(self.errors)
email = self.parse_email(r.email)
krestni = self.parse_name(r.krestni)
prijmeni = self.parse_name(r.prijmeni)
- misto = self.parse_opt_place(r.kod_mista, 'místo')
+ role = self.parse_role(r.role)
+ kat: str = getattr(r, "kategorie", "")
+ if kat:
+ kategorie = self.parse_category(kat)
+ else:
+ kategorie = self.default_cat
+ code = getattr(r, "kolo", "") or self.default_code
+ if code:
+ kolo = self.parse_master_round(self.year, kategorie, code)
+ if not kolo:
+ return
+ else:
+ kolo = self.round
- if misto is None:
- return self.error('Kód místa je povinné uvést')
+ oblast = self.parse_opt_place(r.kod_oblasti, 'oblast')
+ if oblast is None:
+ oblast = self.default_place
+ if oblast is None:
+ return self.error('Chybí oblast.')
if (len(self.errors) > num_prev_errs
or email is None
or krestni is None
- or prijmeni is None):
+ or prijmeni is None
+ or role is None):
return
user = self.find_or_create_user(email, krestni, prijmeni, is_org=True)
if user is None:
return
- self.add_role(user, misto, db.RoleType.dozor)
+ self.add_role(user, role, oblast, self.year, kategorie, kolo)
+
+ def get_after_import_message(self) -> str:
+ return f'Importováno ({self.cnt_rows} řádků, založeno {self.cnt_new_users} uživatelů, {self.cnt_new_roles} rolí)'
@dataclass
-class JudgeImportRow(mo.csv.Row):
- email: str = ""
- krestni: str = ""
- prijmeni: str = ""
- kod_oblasti: str = ""
+class GlobalOrgsImportRow(OrgsImportRow):
+ kategorie: str = ""
+ kolo: str = ""
-class JudgeImport(Import):
- row_class = JudgeImportRow
- row_example = JudgeImportRow(
+class GlobalOrgsImport(OrgsImport):
+ row_class = GlobalOrgsImportRow
+ row_example = GlobalOrgsImportRow(
email='nekdo@example.org',
krestni='Pokusný',
prijmeni='Králík',
- kod_oblasti='B',
+ kod_oblasti='#3333',
+ role='dozor',
+ kategorie='Z',
+ kolo='',
)
- log_msg_prefix = 'Opravovatelé'
- log_details = {'action': 'import-judges'}
- template_basename = 'sablona-oprav'
- root_place: db.Place
-
- def setup(self):
- assert self.round is not None
- self.root_place = db.get_root_place()
-
- def import_row(self, r: mo.csv.Row):
- assert isinstance(r, JudgeImportRow)
- num_prev_errs = len(self.errors)
- email = self.parse_email(r.email)
- krestni = self.parse_name(r.krestni)
- prijmeni = self.parse_name(r.prijmeni)
- oblast = self.parse_opt_place(r.kod_oblasti, 'oblast')
-
- if (len(self.errors) > num_prev_errs
- or email is None
- or krestni is None
- or prijmeni is None):
- return
-
- user = self.find_or_create_user(email, krestni, prijmeni, is_org=True)
- if user is None:
- return
-
- contest = self.obtain_contest(oblast, allow_none=True)
- place = contest.place if contest else self.root_place
- if not self.check_rights(place):
- return self.error(f'Nemáte práva na správu soutěže {place.name_locative()}')
-
- self.add_role(user, place, db.RoleType.opravovatel)
@dataclass
@@ -589,7 +646,23 @@ class PointsImport(Import):
row_class = PointsImportRow
log_msg_prefix = 'Body'
- def setup(self):
+ allow_add_del: bool # je povoleno zakládat/mazat řešení
+
+ def __init__(
+ self,
+ user: db.User,
+ round: db.Round,
+ task: db.Task,
+ contest: Optional[db.Contest] = None,
+ only_region: Optional[db.Place] = None,
+ allow_add_del: bool = False,
+ ):
+ super().__init__(user)
+ self.round = round
+ self.contest = contest
+ self.task = task
+ self.only_region = only_region
+ self.allow_add_del = allow_add_del
assert self.round is not None
assert self.task is not None
self.log_details = {'action': 'import-points', 'task': self.task.code}
@@ -613,7 +686,7 @@ class PointsImport(Import):
return query
- def import_row(self, r: mo.csv.Row):
+ def import_row(self, r: mo.csv.Row) -> None:
assert isinstance(r, PointsImportRow)
num_prev_errs = len(self.errors)
user_id = self.parse_user_id(r.user_id)
@@ -726,35 +799,5 @@ class PointsImport(Import):
mo.csv.write(file=out, fmt=self.fmt, row_class=self.row_class, rows=rows)
return out.getvalue()
-
-def create_import(user: db.User,
- type: ImportType,
- fmt: FileFormat,
- round: Optional[db.Round] = None,
- contest: Optional[db.Contest] = None,
- only_region: Optional[db.Place] = None,
- task: Optional[db.Task] = None,
- allow_add_del: bool = False):
- imp: Import
- if type == ImportType.participants:
- imp = ContestImport()
- elif type == ImportType.proctors:
- imp = ProctorImport()
- elif type == ImportType.judges:
- imp = JudgeImport()
- elif type == ImportType.points:
- imp = PointsImport()
- else:
- assert False, "Neznámý typ importu"
-
- imp.user = user
- imp.round = round
- imp.contest = contest
- imp.only_region = only_region
- imp.task = task
- imp.allow_add_del = allow_add_del
- imp.fmt = fmt
- imp.gatekeeper = mo.rights.Gatekeeper(user)
- imp.setup()
-
- return imp
+ def get_after_import_message(self) -> str:
+ return f'Importováno ({self.cnt_rows} řádků, {self.cnt_set_points} řešení přebodováno, {self.cnt_add_sols} založeno a {self.cnt_del_sols} smazáno)'
diff --git a/mo/rights.py b/mo/rights.py
index b0e8878a0244ec1f2db399956c074892a8e025fc..5bca35d5b5d59b3cfe8ac8935f83d93d6ecb378d 100644
--- a/mo/rights.py
+++ b/mo/rights.py
@@ -358,6 +358,7 @@ class Gatekeeper:
roles: List[db.UserRole]
parent_cache: Dict[int, List[db.Place]]
rights_cache: Dict[Tuple[Optional[int], Optional[int], Optional[str], Optional[int], Optional[db.RoleType]], Rights]
+ root_place: Optional[db.Place]
def __init__(self, user: db.User):
self.user = user
@@ -365,6 +366,12 @@ class Gatekeeper:
assert user.is_org or user.is_admin
self.parent_cache = {}
self.rights_cache = {}
+ self.root_place = None
+
+ def get_root_place(self) -> db.Place:
+ if not self.root_place:
+ self.root_place = db.get_root_place()
+ return self.root_place
def get_ancestors(self, place: db.Place) -> List[db.Place]:
pid = place.place_id
@@ -384,7 +391,7 @@ class Gatekeeper:
"""Posbírá role a práva, která se vztahují k danému místu (možno i tranzitivně) a soutěži.
Pokud place=None, omezení role na místo se nebere v úvahu.
Pokud year==None, vyhovují role s libovolným ročníkem; pokud year=0, vyhovují jen ty s neuvedeným ročníkem.
- Podobně cat a seq.
+ Podobně cat a seq (u cat vyhoví jen ty s neuvedenou kategorií, když cat="").
Pokud min_role!=None, tak se uvažují jen role, které jsou v hierarchii alespoň na úrovni min_role."""
cache_key = (place.place_id if place is not None else None, year, cat, seq, min_role)
@@ -425,7 +432,7 @@ class Gatekeeper:
elif for_place:
place = for_place
else:
- place = db.get_root_place()
+ place = self.get_root_place()
rights = RoundRights()
rights.round = round
rights._clone_from(self.rights_for(
diff --git a/mo/users.py b/mo/users.py
index 6bbe2d245752129e42b76ad6be6c77ae4328dfe3..851ddf09c345e6b3e4ca7a7b16befb54a5cdb829 100644
--- a/mo/users.py
+++ b/mo/users.py
@@ -52,10 +52,34 @@ def validate_and_find_school(kod: str) -> db.Place:
return place
-def find_or_create_user(email: str, krestni: Optional[str], prijmeni: Optional[str], is_org: bool, reason: str) -> Tuple[db.User, bool]:
+class CheckErrorOrgIsUser(mo.CheckError):
+ """Při požadavku na orga nalezen uživatel nebo opačně."""
+ pass
+
+
+def change_user_to_org(user, reason: str):
+ if (db.get_session().query(db.Participation, db.Contest, db.Round)
+ .select_from(db.Participation)
+ .join(db.Contest)
+ .filter(db.Participation.user == user)
+ .filter(db.Round.year == config.CURRENT_YEAR)
+ .count()):
+ raise mo.CheckError("Převedení účastníka na organizátora se nezdařilo, protože se účastní aktuálního ročníku. Kontaktujte prosím správce.")
+ user.is_org = True
+ logger.info(f'{reason.title()}: Změna stavu uživatele user=#{user.user_id} na organizátora')
+ changes = db.get_object_changes(user)
+ mo.util.log(
+ type=db.LogType.user,
+ what=user.user_id,
+ details={'action': 'user-change-is-org', 'reason': reason, 'changes': changes},
+ )
+
+
+def find_or_create_user(email: str, krestni: Optional[str], prijmeni: Optional[str], is_org: bool, reason: str, allow_change_user_to_org=False) -> Tuple[db.User, bool, bool]:
sess = db.get_session()
user = sess.query(db.User).filter_by(email=email).one_or_none()
is_new = user is None
+ is_change_user_to_org = False
if user is None: # HACK: Podmínku je nutné zapsat znovu místo užití is_new, jinak si s tím mypy neporadí
if not krestni or not prijmeni:
raise mo.CheckError('Osoba s daným emailem zatím neexistuje, je nutné uvést její jméno.')
@@ -73,10 +97,14 @@ def find_or_create_user(email: str, krestni: Optional[str], prijmeni: Optional[s
raise mo.CheckError(f'Osoba již registrována s odlišným jménem {user.full_name()}')
if (user.is_admin or user.is_org) != is_org:
if is_org:
- raise mo.CheckError('Nelze předefinovat účastníka na organizátora')
+ if allow_change_user_to_org:
+ change_user_to_org(user, reason)
+ is_change_user_to_org = True
+ else:
+ raise CheckErrorOrgIsUser('Nelze předefinovat účastníka na organizátora.')
else:
- raise mo.CheckError('Nelze předefinovat organizátora na účastníka')
- return user, is_new
+ raise mo.CheckError('Nelze předefinovat organizátora na účastníka.')
+ return user, is_new, is_change_user_to_org
def find_or_create_participant(user: db.User, year: int, school_id: Optional[int], birth_year: Optional[int], grade: Optional[str], reason: str) -> Tuple[db.Participant, bool]:
diff --git a/mo/web/imports.py b/mo/web/imports.py
new file mode 100644
index 0000000000000000000000000000000000000000..c9a84cb76cf63223e235ddf0fda24840fccdb5e3
--- /dev/null
+++ b/mo/web/imports.py
@@ -0,0 +1,61 @@
+from flask import render_template, g, redirect, url_for, flash, request
+from flask_wtf import FlaskForm
+import flask_wtf.file
+import wtforms
+import wtforms.validators as validators
+from typing import Optional
+
+import mo
+from mo.imports import Import, FileFormat
+from mo.web import app
+
+
+class ImportForm(FlaskForm):
+ file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True})
+ fmt = wtforms.SelectField(
+ "Formát souboru",
+ choices=FileFormat.choices(), coerce=FileFormat.coerce,
+ default=FileFormat.cs_csv,
+ )
+ submit = wtforms.SubmitField('Importovat')
+ get_template = wtforms.SubmitField('Stáhnout šablonu')
+
+
+def generic_import_page(form: ImportForm, imp: Optional[Import], redirect_url: str, template: str = 'org_generic_import.html', **kwargs):
+ # Případné další parametry (**kwargs) jsou předávány generování stránky
+ errs = []
+ warnings = []
+ if imp:
+ fmt = form.fmt.data
+ imp.fmt = fmt
+ if form.submit.data:
+ if form.file.data is not None:
+ file = form.file.data.stream
+ import_tmp = mo.util.link_to_dir(file.name, mo.util.data_dir('imports'), suffix='.csv')
+
+ if imp.run(import_tmp):
+ if imp.cnt_rows == 0:
+ flash('Soubor neobsahoval žádné řádky s daty', 'danger')
+ else:
+ flash(imp.get_after_import_message(), 'success')
+ return redirect(redirect_url)
+ else:
+ errs = imp.errors
+ warnings = imp.warnings
+ else:
+ flash('Vyberte si prosím soubor', 'danger')
+ elif form.get_template.data:
+ out = imp.get_template()
+ resp = app.make_response(out)
+ resp.content_type = fmt.get_content_type()
+ resp.headers.add('Content-Disposition', 'attachment; filename=OSMO-' + imp.template_basename + '.' + fmt.get_extension())
+ return resp
+
+ return render_template(
+ template,
+ errs=errs,
+ warnings=warnings,
+ form=form,
+ imp=imp,
+ **kwargs
+ )
diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py
index a68bd79a44bb5417931c6efb4075cec51cc8f003..823c12bbf2f7782ef0802b515c3b98c8c2234900 100644
--- a/mo/web/org_contest.py
+++ b/mo/web/org_contest.py
@@ -7,7 +7,6 @@ from sqlalchemy import func, and_, select
from sqlalchemy.orm import joinedload, aliased
from sqlalchemy.orm.query import Query
from sqlalchemy.dialects.postgresql import insert as pgsql_insert
-import sqlalchemy.sql.schema
from typing import Any, List, Tuple, Optional, Dict
import urllib.parse
import werkzeug.exceptions
@@ -16,16 +15,16 @@ import wtforms.validators as validators
from wtforms.widgets.html5 import NumberInput
import mo
-from mo.csv import FileFormat
import mo.config as config
import mo.db as db
-from mo.imports import ImportType, create_import
+from mo.imports import PointsImport, ContestImport, OrgsImport
import mo.jobs.submit
from mo.rights import Right, RoundRights
import mo.util
from mo.util_format import inflect_number, inflect_by_number
from mo.web import app
import mo.web.fields as mo_fields
+from mo.web.imports import ImportForm, generic_import_page
import mo.web.util
from mo.web.util import PagerForm
from mo.web.table import CellCheckbox, Table, Row, Column, cell_pion_link, cell_place_link, cell_email_link_flags
@@ -413,70 +412,73 @@ def org_contest(ct_id: int, site_id: Optional[int] = None):
)
-class ImportForm(FlaskForm):
- file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True})
- typ = wtforms.SelectField(
- "Typ dat",
- choices=[(x.name, x.friendly_name()) for x in (ImportType.participants, ImportType.proctors, ImportType.judges)],
- coerce=ImportType.coerce,
- default=ImportType.participants,
- )
- fmt = wtforms.SelectField(
- "Formát souboru",
- choices=FileFormat.choices(), coerce=FileFormat.coerce,
- default=FileFormat.cs_csv,
- )
- submit = wtforms.SubmitField('Importovat')
- get_template = wtforms.SubmitField('Stáhnout šablonu')
+class ContestantImportForm(ImportForm):
+ pass
-@app.route('/org/contest/c/<int:ct_id>/import', methods=('GET', 'POST'))
-@app.route('/org/contest/r/<int:round_id>/import', methods=('GET', 'POST'))
-@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/import', methods=('GET', 'POST'))
-def org_generic_import(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None):
+@app.route('/org/contest/c/<int:ct_id>/import-contestant', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/import-contestant', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/import-contestant', methods=('GET', 'POST'))
+def org_import_user(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None):
ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, right_needed=Right.manage_contest)
round, contest = ctx.master_round, ctx.master_contest
- form = ImportForm()
- errs = []
- warnings = []
- if form.validate_on_submit():
- fmt = form.fmt.data
- imp = create_import(user=g.user, type=form.typ.data, fmt=fmt, round=round, contest=contest, only_region=ctx.hier_place)
- if form.submit.data:
- if form.file.data is not None:
- file = form.file.data.stream
- import_tmp = mo.util.link_to_dir(file.name, mo.util.data_dir('imports'), suffix='.csv')
-
- if imp.run(import_tmp):
- if imp.cnt_rows == 0:
- flash('Soubor neobsahoval žádné řádky s daty', 'danger')
- else:
- flash(f'Importováno ({imp.cnt_rows} řádků, založeno {imp.cnt_new_users} uživatelů, {imp.cnt_new_participations} účastí, {imp.cnt_new_roles} rolí)', 'success')
- return redirect(ctx.url_home())
- else:
- errs = imp.errors
- warnings = imp.warnings
- else:
- flash('Vyberte si prosím soubor', 'danger')
- elif form.get_template.data:
- out = imp.get_template()
- resp = app.make_response(out)
- resp.content_type = fmt.get_content_type()
- resp.headers.add('Content-Disposition', 'attachment; filename=OSMO-' + imp.template_basename + '.' + fmt.get_extension())
- return resp
+ default_place = contest.place if contest else ctx.hier_place
- return render_template(
- 'org_generic_import.html',
+ form = ContestantImportForm()
+ imp = None
+ if form.validate_on_submit():
+ imp = ContestImport(
+ user=g.user,
+ round=round,
+ contest=contest,
+ only_region=ctx.hier_place,
+ default_place=default_place,
+ )
+ return generic_import_page(
+ form, imp, ctx.url_home(),
+ template='org_contestants_import.html',
ctx=ctx,
contest=contest,
round=round,
- form=form,
- errs=errs,
- warnings=warnings
+ default_place=default_place
)
+class OrgImportForm(ImportForm):
+ allow_change_user_to_org = wtforms.BooleanField("Povolit převádění účastníků na organizátory")
+
+
+@app.route('/org/contest/c/<int:ct_id>/import-org', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/import-org', methods=('GET', 'POST'))
+@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/import-org', methods=('GET', 'POST'))
+def org_import_org(round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None):
+ ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, right_needed=Right.manage_contest)
+ round, contest = ctx.master_round, ctx.master_contest
+
+ default_place = contest.place if contest else ctx.hier_place
+
+ form = OrgImportForm()
+ imp = None
+ if form.validate_on_submit():
+ imp = OrgsImport(
+ user=g.user,
+ round=round,
+ contest=contest,
+ only_region=ctx.hier_place,
+ default_place=default_place,
+ allow_change_user_to_org=form.allow_change_user_to_org.data
+ )
+ return generic_import_page(
+ form, imp, ctx.url_home(),
+ template='org_orgs_import.html',
+ ctx=ctx,
+ contest=contest,
+ round=round,
+ default_place=default_place
+ )
+
+
# URL je explicitně uvedeno v mo.email.contestant_list_url
@app.route('/org/contest/c/<int:ct_id>/participants', methods=('GET', 'POST'))
@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/participants', methods=('GET', 'POST'))
@@ -1300,16 +1302,8 @@ def org_generic_batch_upload(task_id: int, round_id: Optional[int] = None, hier_
)
-class BatchPointsForm(FlaskForm):
- file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True})
- fmt = wtforms.SelectField(
- "Formát souboru",
- choices=FileFormat.choices(), coerce=FileFormat.coerce,
- default=FileFormat.cs_csv,
- )
+class BatchPointsForm(ImportForm):
add_del_sols = wtforms.BooleanField('Zakládat / mazat řešení', description='Xyzzy')
- submit = wtforms.SubmitField('Nahrát body')
- get_template = wtforms.SubmitField('Stáhnout šablonu')
@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST'))
@@ -1318,46 +1312,20 @@ class BatchPointsForm(FlaskForm):
def org_generic_batch_points(task_id: int, round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None):
ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, task_id=task_id)
round, hier_place, contest, task = ctx.round, ctx.hier_place, ctx.contest, ctx.task
+ assert task
if not ctx.rights.can_edit_points():
raise werkzeug.exceptions.Forbidden()
form = BatchPointsForm()
- errs = []
- warnings = []
+ imp = None
if form.validate_on_submit():
- fmt = form.fmt.data
- imp = create_import(user=g.user, type=ImportType.points, fmt=fmt, round=round, only_region=hier_place, contest=contest, task=task, allow_add_del=form.add_del_sols.data)
- if form.submit.data:
- if form.file.data is not None:
- file = form.file.data.stream
- import_tmp = mo.util.link_to_dir(file.name, mo.util.data_dir('imports'), suffix='.csv')
-
- if imp.run(import_tmp):
- if imp.cnt_rows == 0:
- flash('Soubor neobsahoval žádné řádky s daty', 'danger')
- else:
- flash(f'Importováno ({imp.cnt_rows} řádků, {imp.cnt_set_points} řešení přebodováno, {imp.cnt_add_sols} založeno a {imp.cnt_del_sols} smazáno)', 'success')
- return redirect(ctx.url_home())
- else:
- errs = imp.errors
- warnings = imp.warnings
- else:
- flash('Vyberte si prosím soubor', 'danger')
- elif form.get_template.data:
- out = imp.get_template()
- resp = app.make_response(out)
- resp.content_type = fmt.get_content_type()
- resp.headers.add('Content-Disposition', 'attachment; filename=OSMO-' + imp.template_basename + '.' + fmt.get_extension())
- return resp
-
- return render_template(
- 'org_generic_batch_points.html',
+ imp = PointsImport(user=g.user, round=round, only_region=hier_place, contest=contest, task=task, allow_add_del=form.add_del_sols.data)
+ return generic_import_page(
+ form, imp, ctx.url_home(),
+ template='org_generic_batch_points.html',
ctx=ctx,
- round=round, contest=contest, task=task,
- form=form,
- errs=errs,
- warnings=warnings
+ round=round, contest=contest, task=task
)
@@ -1634,7 +1602,7 @@ def org_contest_add_user(ct_id: int, site_id: Optional[int] = None):
if form.validate_on_submit():
try:
- user, is_new_user = mo.users.find_or_create_user(form.email.data, form.first_name.data, form.last_name.data, False, reason='web')
+ user, is_new_user, is_change_user_to_org = mo.users.find_or_create_user(form.email.data, form.first_name.data, form.last_name.data, False, reason='web')
participant, is_new_participant = mo.users.find_or_create_participant(user, contest.round.year, form.school.get_place_id(), form.birth_year.data, form.grade.data, reason='web')
participation, is_new_participation = mo.users.find_or_create_participation(user, contest, form.participation_place.get_place(), reason='web')
except mo.CheckError as e:
diff --git a/mo/web/org_users.py b/mo/web/org_users.py
index 0f8b055ab82e8fef7d32c823f06ff306fb1af05e..3ef60af3fb1e5444181611c33489f5f1b213f65d 100644
--- a/mo/web/org_users.py
+++ b/mo/web/org_users.py
@@ -15,11 +15,13 @@ from wtforms.validators import Required
import mo
import mo.db as db
import mo.email
+from mo.imports import GlobalOrgsImport
from mo.rights import Right
import mo.util
import mo.users
from mo.web import app
import mo.web.fields as mo_fields
+from mo.web.imports import ImportForm, generic_import_page
from mo.web.util import PagerForm
@@ -239,7 +241,7 @@ class FormAddRole(FlaskForm):
place = mo_fields.Place()
year = wtforms.IntegerField('Ročník', validators=[validators.Optional()])
category = wtforms.StringField("Kategorie", validators=[validators.Length(max=2)], filters=[lambda x: x or None])
- seq = wtforms.IntegerField("Kolo", validators=[validators.Optional()])
+ seq = wtforms.IntegerField("Kolo", render_kw={"placeholder": "Pořadí kola v kategorii"}, validators=[validators.Optional()])
submit = wtforms.SubmitField('Přidat roli')
@@ -264,6 +266,10 @@ class ResendInviteForm(FlaskForm):
flash('Tento uživatel už má účet aktivovaný.', 'danger')
+class UpgradeToOrgForm(FlaskForm):
+ upgrade = SubmitField()
+
+
@app.route('/org/org/<int:id>/', methods=('GET', 'POST'))
def org_org(id: int):
sess = db.get_session()
@@ -371,6 +377,19 @@ def org_user(id: int):
resend_invite_form.do(user)
return redirect(url_for('org_user', id=id))
+ upgrade_form: Optional[UpgradeToOrgForm] = None
+ if rr.can_edit_user:
+ upgrade_form = UpgradeToOrgForm()
+ if upgrade_form.upgrade.data and upgrade_form.validate_on_submit():
+ try:
+ mo.users.change_user_to_org(user, reason='web')
+ sess.commit()
+ flash('Účet změněn na organizátorský.', 'success')
+ return redirect(url_for('org_org', id=user.user_id))
+ except mo.CheckError as e:
+ flash(str(e), 'danger')
+ return redirect(url_for('org_user', id=user.user_id))
+
participants = sess.query(db.Participant).filter_by(user_id=user.user_id)
participations = (
sess.query(db.Participation, db.Contest, db.Round)
@@ -388,6 +407,7 @@ def org_user(id: int):
can_incarnate=g.user.is_admin,
participants=participants, participations=participations,
resend_invite_form=resend_invite_form,
+ upgrade_form=upgrade_form,
)
@@ -398,6 +418,7 @@ class UserEditForm(FlaskForm):
note = wtforms.TextAreaField("Poznámka")
is_test = wtforms.BooleanField("Testovací účet")
allow_duplicate_name = wtforms.BooleanField("Přidat účet s duplicitním jménem")
+ allow_change_user_to_org = wtforms.BooleanField("Povolit převedení účastníka na organizátora")
submit = wtforms.SubmitField("Uložit")
@@ -422,6 +443,7 @@ def org_user_edit(id: int):
form = UserEditForm(obj=user)
del form.allow_duplicate_name
+ del form.allow_change_user_to_org
if (user.is_org or user.is_admin) and not g.user.is_admin:
# emaily u organizátorů může editovat jen správce
del form.email
@@ -471,12 +493,35 @@ def org_user_new():
form = UserEditForm()
form.submit.label.text = 'Vytvořit'
is_duplicate_name = False
+ allow_change_user_to_org_show_field = False
if form.validate_on_submit():
check = True
- if mo.users.user_by_email(form.email.data) is not None:
- flash('Účet s daným e-mailem již existuje', 'danger')
- check = False
+ old_user = mo.users.user_by_email(form.email.data)
+ if old_user is not None:
+ if is_org and not old_user.is_org:
+ allow_change_user_to_org_show_field = True
+ if form.allow_change_user_to_org.data:
+ try:
+ mo.users.find_or_create_user(
+ email=form.email.data,
+ krestni=form.first_name.data,
+ prijmeni=form.last_name.data,
+ is_org=True,
+ reason="web",
+ allow_change_user_to_org=True)
+ except mo.CheckError as e:
+ flash(str(e), 'danger')
+ check = False
+ if check:
+ mo.db.get_session().commit()
+ return redirect(url_for('org_org', id=old_user.user_id))
+ if check:
+ flash('Účet s daným e-mailem již existuje. Převedení účastníka na organizátora můžete povolit ve formuláři.', 'danger')
+ check = False
+ if check:
+ flash('Účet s daným e-mailem již existuje.', 'danger')
+ check = False
if is_org:
if (mo.db.get_session().query(db.User)
@@ -514,8 +559,11 @@ def org_user_new():
return redirect(url_for('org_org', id=new_user.user_id))
return redirect(url_for('org_user', id=new_user.user_id))
- if not is_duplicate_name:
+ if not is_duplicate_name and not form.allow_duplicate_name.data:
del form.allow_duplicate_name
+
+ if not (is_org and allow_change_user_to_org_show_field):
+ del form.allow_change_user_to_org
return render_template('org_user_new.html', form=form, is_org=is_org)
@@ -561,3 +609,26 @@ def org_user_participant_edit(user_id: int, year: int):
return redirect(url_for('org_user', id=user_id))
return render_template('org_user_participant_edit.html', user=user, year=year, form=form)
+
+
+class GlobalOrgsImportForm(ImportForm):
+ allow_change_user_to_org = wtforms.BooleanField("Povolit převádění účastníků na organizátory")
+ default_place = mo_fields.Place("Výchozí oblast (není-li v souboru uvedena)")
+ default_cat = wtforms.StringField("Výchozí kategorie (není-li v souboru uvedena)")
+ default_code = wtforms.StringField("Výchozí kód kola (není-li v souboru uveden)")
+ only_this_year = wtforms.BooleanField("Omezit práva na aktuální ročník", default=True)
+
+
+@app.route('/org/org/import', methods=('GET', 'POST'))
+def org_orgs_import():
+ form = GlobalOrgsImportForm()
+ imp = None
+ if form.validate_on_submit():
+ imp = GlobalOrgsImport(
+ g.user,
+ default_place=form.default_place.place,
+ default_cat=form.default_cat.data,
+ default_code=form.default_code.data,
+ year=mo.config.CURRENT_YEAR if form.only_this_year.data else None
+ )
+ return generic_import_page(form, imp, url_for('org_orgs_import'), template='org_global_orgs_import.html')
diff --git a/mo/web/templates/doc_garant.html b/mo/web/templates/doc_garant.html
index 493acc0d47d20bb843d653bfa9c2da08cdb65d44..6bd001cdf30f284174d52d4293c99fa2ed513170 100644
--- a/mo/web/templates/doc_garant.html
+++ b/mo/web/templates/doc_garant.html
@@ -34,6 +34,11 @@ oblasti (kraje, okresy, školy), kategorie či kola soutěže.
i formulář na přidělování rolí.
</ul>
+<p>Může se stát, že budoucí organizátor už má účastnický účet (je to bývalý účastník
+nebo si pomocí registrace účet založil sám). Pak je potřeba účet nejdřív převést
+na organizátorský. To jde udělat vyhledáním uživatele mezi účastníky a zmáčknutím
+tlačítka pro převod účtu.
+
<h3>Hierarchie míst</h3>
<p>Jednotlivé oblasti a školy, kde se soutěží, jsou zařazeny do hierarchie <b>míst</b>.
@@ -77,6 +82,7 @@ Držte se prosím následujících konvencí:
<li><b>ročník MO</b> (nyní 70)
<li><b>kategorie</b> (A, P, Z5, …)
<li><b>pořadí</b> v rámci kategorie (1, 2, …)
+ <li><b>kód</b> v rámci kategorie (zobrazuje se místo pořadí, třeba „S“ pro školní kolo)
<li><b>část</b> – u běžných kol 0, jinak viz níže
<li><b>úroveň</b> v hierarchii míst, na které se odehrává (to může být celá republika,
kraj, okres apod.). Odpovídá tomu, pro jaké oblasti se sestavují samostatné
@@ -93,17 +99,17 @@ Např. krajské kolo má samostatnou soutěž v každém kraji.
<p>Soutěžní kolo se nachází v jednom z následujících stavů:
<ul>
- <li>připravuje se – kolo je zatím přístupné jenom organizátorům;
+ <li><b>připravuje se</b> – kolo je zatím přístupné jenom organizátorům;
účastníci vidí jen, že kolo existuje (pokud jsou do něj pozvaní),
a termín začátku soutěže.
- <li>běží – účastníkům je dostupné zadání (po zadaném čase) a mohou odevzdávat,
+ <li><b>běží</b> – účastníkům je dostupné zadání (po zadaném čase) a mohou odevzdávat,
dozor také může odevzdávat. Opravovatelé si mohou průběžně prohlížet
odevzdané úlohy, ale ještě nemohou nic měnit.
- <li>opravuje se – opravovatelé si mohou stahovat finální verzi řešení, nahrávat opravená
+ <li><b>opravuje se</b> – opravovatelé si mohou stahovat finální verzi řešení, nahrávat opravená
řešení a vyplňovat body a poznámky.
- <li>ukončeno – opravená řešení, body a poznámky jsou dostupné účastníkům (vše pouze
+ <li><b>ukončeno</b> – opravená řešení, body a poznámky jsou dostupné účastníkům (vše pouze
ve finální verzi), opravovatelé už nemohou nic měnit.
- <li>po oblastech – soutěž v každé oblasti si může nastavit svůj stav (viz níže)
+ <li><b>po oblastech</b> – soutěž v každé oblasti si může nastavit svůj stav (viz níže)
</ul>
<p>Omezení daná stavem soutěže neplatí pro garanty, ti mohou vždy všechno.
@@ -124,7 +130,24 @@ Každá oblast nyní bude ve stavu <em>běží</em> (zdědila předchozí nastav
oblasti může podle potřeby přepínat do dalších stavů. Až budou všechny oblasti ve stavu <em>ukončeno</em>,
celostátní garant kolo také přepne do <em>ukončeno.</em>
-<h3>Účastníci</h3>
+<h3>Registrace</h3>
+
+<p>Účastníci si mohou sami založit účet a pak se pomocí něj přihlásit do domácího kola.
+Přesněji řečeno každé kolo může mít nastaven jeden ze tří režimů registrace:
+
+<ul>
+ <li><b>účastníci sami</b> – účastník se registruje sám; používáme v kategorii P
+ <li><b>potvrzení organizátorem</b> – účastník se registruje sám, ale organizátor
+ musí registraci potvrdit (převést účast ze stavu „přihlášený“ do „soutěží“);
+ používáme v ostatních kategoriích
+ <li><b>jen organizátoři</b> – účastníky přihlašuje organizátor
+
+</ul>
+
+<p>Do vyšších kol obvykle účastníky nepřihlašujeme přímo, ale používáme
+tlačítko „Postup z minulého kola“ na stránce soutěže.
+
+<h3>Import účastníku</h3>
<p>Garanti mohou přihlašovat účastníky do soutěže <b>importem</b> souboru ve formátu CSV.
Tento soubor můžete vyrobit v Excelu či jiném tabulkovém kalkulátoru a pak do CSV exportovat.
@@ -158,26 +181,13 @@ místo pak může mít svůj <b>dozor</b> (viz popis rolí). Typické situace js
<p>Pokud importujete účastníka, který dosud neměl založen účet, účet se automaticky vytvoří
a účastníkovi se pošle e-mail s odkazem na nastavení hesla.
-<h3>Dozor</h3>
+<h3>Import organizátorů</h3>
-<p>Osoby vykonávající dozor na soutěžních místech jde také hromadně importovat.
+<p>Účty a role organizátorů je také možné zakládat hromadně pomocí importu.
Funguje to podobně jako import účastníků, opět je k dispozici <a href='{{ url_for('doc_import') }}'>popis formátu</a>.
-<p>Dozírajícím se automaticky založí organizátorské účty (pokud je ještě nemají) a přidělí
-se jim dozorová role k příslušnému kolu a soutěžnímu místu.
-
-<p>Dozor se ke svému soutěžnímu místu dostane přes Soutěž » výběr kola » výběr soutěže »
-výběr soutěžního místa. Pak si může prohlížet seznam účastníků a odevzdané úlohy
-a také za účastníky odevzdávat.
-
-<h3>Opravovatelé</h3>
-
-<p>Opravovatelé si mohou prohlížet účastnická řešení, nahrávat do systému jejich opravené verze
-a udělovat body a poznámky.
-
-<p>Také je možné stáhnout si najednou všechna účastnická řešení jako jeden ZIP, do řešení
-dopsat poznámky a pak je zase jako ZIP nahrát zpět. Přitom je nutné zachovat jména souborů.
-
-<p>Podobně jako dozor, i opravovatele můžete importovat.
+<p>Importovat organizátory jde do konkrétní soutěže (na stránce soutěže, přidělí se role omezené
+na tuto soutěž), do konkrétního kola (na stránce kola) nebo obecně (v záložce „Organizátoři“,
+tak lze zakládat neomezené organizátorské role, což se hodí například pro garanty).
{% endblock %}
diff --git a/mo/web/templates/doc_import.html b/mo/web/templates/doc_import.html
index 163f9badd9770309f86d984b11b012593139bd48..86b95321dfcf0f5168870be0ba353b57c3915710 100644
--- a/mo/web/templates/doc_import.html
+++ b/mo/web/templates/doc_import.html
@@ -41,30 +41,36 @@ když přidáte vlastní sloupce s novými názvy, budou se ignorovat.
rozporu mezi importovanými údaji a již známými import selže a je nutné provést
editaci ručně.
-<h2>Import dozoru</h2>
+<h2>Import organizátorů</h2>
-<p>Definovány jsou tyto sloupce (tučné jsou povinné):
+<p>Definovány jsou tyto sloupce (tučné jsou povinné, kurzívou jsou povinné pro zatím nezaregistrované účty):
<table class=data>
<tr><th>Název<th>Obsah
<tr><td><b>email</b><td>E-mailová adresa
- <tr><td><b>krestni</b><td>Křestní jméno
- <tr><td><b>prijmeni</b><td>Příjmení
- <tr><td><b>kod_mista</b><td>Kód soutěžního místa (viz katalog škol na tomto webu)
+ <tr><td><i>krestni</i><td>Křestní jméno
+ <tr><td><i>prijmeni</i><td>Příjmení
+ <tr><td>kod_oblasti<td>Pokud neimportujete do konkrétní soutěže,
+ můžete uvést kód oblasti, na kterou bude mít organizátor omezená práva.
+ V opačném případě bude mít práva ke všem oblastem.
+ <tr><td><b>role</b><td>Jedna z následujících rolí:
+ garant,
+ garant_kraj,
+ garant_okres,
+ garant_skola,
+ dozor,
+ opravovatel.
</table>
-<h2>Import opravovatelů</h2>
-
-<p>Definovány jsou tyto sloupce (tučné jsou povinné):
+V obecném importu organizátorů krom výše uvedených existuje ještě:
<table class=data>
<tr><th>Název<th>Obsah
- <tr><td><b>email</b><td>E-mailová adresa
- <tr><td><b>krestni</b><td>Křestní jméno
- <tr><td><b>prijmeni</b><td>Příjmení
- <tr><td>kod_oblasti<td>Pokud neimportujete do konkrétní soutěže, ale do celého kola,
- můžete uvést kód oblasti, ve které opravovatel pracuje.
- V opačném případě bude mít práva ke všem oblastem.
+ <tr><td>kategorie<td>Omezí práva na příslušnou kategorii.
+ Kategorie Z funguje pro všechny kategorie pro základní školy a kategorie S pro všechny kategorie A, B, C.
+ <tr><td>kolo<td>Omezí práva organizátora pouze do příslušného kola.
+ Je nutné současně určit i kategorii a nelze kombinovat s importem bez omezení na aktuální ročník.
+ Kolo se zadává pomocí kódu (například 1, S, 2).
</table>
{% endblock %}
diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html
index cbef413b21e0c2a0d2b81896cdd048a96295f9ed..9fa6826d00312dd9620ba10d6ff2250918ffc37a 100644
--- a/mo/web/templates/org_contest.html
+++ b/mo/web/templates/org_contest.html
@@ -64,7 +64,8 @@
<a class="btn btn-primary" href='{{ ctx.url_for('org_contest_advance') }}'>Postup z minulého kola</a>
{% endif %}
{% if can_manage %}
- <a class="btn btn-default" href='{{ ctx.url_for('org_generic_import') }}'>Importovat data</a>
+ <a class="btn btn-default" href='{{ ctx.url_for('org_import_user') }}'>Importovat účastníky</a>
+ <a class="btn btn-default" href='{{ ctx.url_for('org_import_org') }}'>Importovat organizátory</a>
{% endif %}
{% if can_manage and not site %}
<a class="btn btn-default" href='{{ ctx.url_for('org_contest_edit') }}'>Nastavení</a>
diff --git a/mo/web/templates/org_contestants_import.html b/mo/web/templates/org_contestants_import.html
new file mode 100644
index 0000000000000000000000000000000000000000..82d27d7e2ca53cd63d8e6955d6f3194cd89863e4
--- /dev/null
+++ b/mo/web/templates/org_contestants_import.html
@@ -0,0 +1,24 @@
+{% extends "org_generic_import.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}
+Import soutěžících {% if contest or round %}do {% if contest %}soutěže {{ contest.place.name_locative() }}{% else %}kola {{ round.round_code() }}{% endif %}{% endif %}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Import soutěžících") }}
+{% endblock %}
+
+{% block import_info %}
+{% if not contest %}
+<p><em>Pozor, zde se importuje do více soutěží najednou, takže je nutné uvádět
+kód oblasti. Nechcete raději importovat do konkrétní oblasti?</em></p>
+{% endif %}
+
+{% if default_place %}
+<p>Výchozí oblastí tohoto importu je: <a href='{{ url_for('org_place', id=default_place.place_id) }}'>{{ default_place.name }}</a>.</p>
+{% endif %}
+{% endblock %}
+
+
+{% block import_form %}
+{% endblock %}
diff --git a/mo/web/templates/org_generic_import.html b/mo/web/templates/org_generic_import.html
index 6ed927d607648ab8660340faeb70a481ebc5831b..bd3906686258d0f34cdc270e67134f82b89e4d65 100644
--- a/mo/web/templates/org_generic_import.html
+++ b/mo/web/templates/org_generic_import.html
@@ -2,13 +2,11 @@
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}
-Import dat do {% if contest %}soutěže {{ contest.place.name_locative() }}{% else %}kola {{ round.round_code() }}{% endif %}
-{% endblock %}
-{% block breadcrumbs %}
-{{ ctx.breadcrumbs(action="Import dat") }}
+Import dat {% if contest or round %}do {% if contest %}soutěže {{ contest.place.name_locative() }}{% else %}kola {{ round.round_code() }}{% endif %}{% endif %}
{% endblock %}
{% block body %}
+{% block import_errs %}
{% if warnings %}
<h3>Varování při importu</h3>
@@ -28,15 +26,24 @@ Import dat do {% if contest %}soutěže {{ contest.place.name_locative() }}{% el
{% endfor %}
</div>
{% endif %}
+{% endblock %}
-<p>Zde je možné importovat účastníky soutěže, dozor na soutěžních místech a opravovatele.
-Detaily fungování importu najdete v <a href='{{ url_for('doc_import') }}'>dokumentaci</a>.
+{% block import_info %}{% endblock %}
-{% if not contest %}
-<p><em>Pozor, zde se importuje do více soutěží najednou, takže je nutné uvádět
-kód oblasti. Nechcete raději importovat do konkrétní oblasti?</em>
-{% endif %}
+{% block import_help %}
+<p>Detaily fungování importu najdete v <a href='{{ url_for('doc_import') }}'>dokumentaci</a>.
+{% endblock %}
+
+<form action="" method="post" class="form" enctype="multipart/form-data" role="form">
+{{ form.csrf_token }}
+{{ wtf.form_field(form.file) }}
+{{ wtf.form_field(form.fmt) }}
+{% block import_form %}{% endblock %}
+ <div class="btn-group">
+ {{ wtf.form_field(form.submit, class='btn btn-primary') }}
+ {{ wtf.form_field(form.get_template, class='btn btn-default') }}
+ </div>
+</form>
-{{ wtf.quick_form(form, form_type='simple', button_map={'submit': 'primary'}) }}
{% endblock %}
diff --git a/mo/web/templates/org_global_orgs_import.html b/mo/web/templates/org_global_orgs_import.html
new file mode 100644
index 0000000000000000000000000000000000000000..0d2abfba84b307c20f5f232a84f7d3752da904c7
--- /dev/null
+++ b/mo/web/templates/org_global_orgs_import.html
@@ -0,0 +1,24 @@
+{% extends "org_generic_import.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}
+Import organizátorů
+{% endblock %}
+
+{% block import_info %}
+<p><em>Toto je obecný import organzátorů. Ten se hodí pro importování „dlouhodobých“ organizátorských rolí,
+které nejsou vázané na konkrétní soutěž. U každého organizátora můžete určit kategorii, kolo
+i oblast jeho působnosti. Pokud chcete raději importovat organizátory konkrétní soutěže, jde to jednodušeji
+přes stránky kola, soutěže, případně soutěžního místa.</em>
+{% endblock %}
+
+
+{% block import_form %}
+ {{ wtf.form_field(form.default_place) }}
+ {{ wtf.form_field(form.default_cat) }}
+ {{ wtf.form_field(form.default_code) }}
+ {{ wtf.form_field(form.only_this_year) }}
+ {% if imp.cnt_change_user_to_org %}
+ {{ wtf.form_field(form.allow_change_user_to_org) }}
+ {% endif %}
+{% endblock %}
diff --git a/mo/web/templates/org_org.html b/mo/web/templates/org_org.html
index 4f9baba97685bad99b6d220ad08df5149daef773..dfeb3a28b22dece4efdcf13e46c6a09a84a4dc35 100644
--- a/mo/web/templates/org_org.html
+++ b/mo/web/templates/org_org.html
@@ -66,7 +66,7 @@ Podobně <code>S</code> znamená všechny středoškolské kategorie <code>A</co
<table class="data full">
<thead>
<tr>
- <th>Role<th>Oblast<th>Ročník<th>Kategorie<th>Kolo<th>Přidělil<th>Akce
+ <th>Role<th>Oblast<th>Ročník<th>Kategorie<th class='has-tip' title='Pořadí kola v kategorii'>Kolo<th>Přidělil<th>Akce
</tr>
</thead>
{% for role in user.roles %}
diff --git a/mo/web/templates/org_orgs.html b/mo/web/templates/org_orgs.html
index 4795f4159d575f524d53907fd9f21ff6ed05509e..7d556a2ac02c668edfe4a8007c44c9db451934ae 100644
--- a/mo/web/templates/org_orgs.html
+++ b/mo/web/templates/org_orgs.html
@@ -3,7 +3,10 @@
{% block title %}Organizátoři{% endblock %}
{% block body %}
{% if can_add %}
-<a class="pull-right btn btn-primary" style="margin-top: -40px;" href="{{ url_for('org_org_new') }}">Nový organizátor</a>
+<div class="btn-group pull-right" style="margin-top: -40px;">
+<a class="btn btn-primary" href="{{ url_for('org_org_new') }}">Nový organizátor</a>
+<a class="btn btn-default" href="{{ url_for('org_orgs_import') }}">Importovat organizátory</a>
+</div>
{% endif %}
<div class="form-frame">
diff --git a/mo/web/templates/org_orgs_import.html b/mo/web/templates/org_orgs_import.html
new file mode 100644
index 0000000000000000000000000000000000000000..2fde575d9b3177b235f0969d291bd442205feeec
--- /dev/null
+++ b/mo/web/templates/org_orgs_import.html
@@ -0,0 +1,30 @@
+{% extends "org_generic_import.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}
+Import organizátorů {% if contest or round %}{% if contest %}soutěže {{ contest.place.name_locative() }}{% else %}kola {{ round.round_code() }}{% endif %}{% endif %}
+{% endblock %}
+{% block breadcrumbs %}
+{{ ctx.breadcrumbs(action="Import organizátorů") }}
+{% endblock %}
+
+{% block import_info %}
+{% if not contest %}
+<p><em>Zde můžete importovat organizátory do více soutěží najednou, takže je nutné uvádět kód oblasti.
+Nechcete raději importovat do konkrétní oblasti na stránce soutěže?</em></p>
+{% else %}
+<p><em>Zde můžete importovat organizátory soutěže. Dostanou organizátorskou roli
+omezenou na tuto konkrétní soutěž.</em></p>
+{% endif %}
+
+{% if default_place %}
+<p>Výchozí oblastí tohoto importu je: <a href='{{ url_for('org_place', id=default_place.place_id) }}'>{{ default_place.name }}</a>.</p>
+{% endif %}
+{% endblock %}
+
+
+{% block import_form %}
+ {% if imp.cnt_change_user_to_org %}
+ {{ wtf.form_field(form.allow_change_user_to_org) }}
+ {% endif %}
+{% endblock %}
diff --git a/mo/web/templates/org_place_rights.html b/mo/web/templates/org_place_rights.html
index 7664ec0d55a84f9186b84ca2eaced16cbf8ee293..22505de047e20e161449d80c7fefc3007b757205 100644
--- a/mo/web/templates/org_place_rights.html
+++ b/mo/web/templates/org_place_rights.html
@@ -11,7 +11,7 @@
<th>Jméno
<th>Roč.
<th>Kat.
- <th>Kolo
+ <th class='has-tip' title='Pořadí kola v kategorii'>Kolo
<th>Zdroj
</thead>
{% for role in roles %}
diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html
index ebeb92d499f8a7aea7bef00b949fb9590d0258fb..0a8ab128d1246d46ba686a142c3ebf2e6ed13cd3 100644
--- a/mo/web/templates/org_round.html
+++ b/mo/web/templates/org_round.html
@@ -92,7 +92,8 @@
<a class="btn btn-primary" href='{{ ctx.url_for('org_score') }}'>Výsledky</a>
{% endif %}
{% if can_manage_contest %}
- <a class="btn btn-default" href='{{ ctx.url_for('org_generic_import') }}'>Importovat data</a>
+ <a class="btn btn-default" href='{{ ctx.url_for('org_import_user') }}'>Importovat účastníky</a>
+ <a class="btn btn-default" href='{{ ctx.url_for('org_import_org') }}'>Importovat organizátory</a>
{% endif %}
{% if can_manage_round %}
<a class="btn btn-default" href='{{ ctx.url_for('org_round_edit') }}'>Nastavení a termíny</a>
diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html
index 9595d8566dcee426aad7afa1425c7ab4ee103145..9155eb29c953a2f8fda028848f6c0a1ca34811cc 100644
--- a/mo/web/templates/org_user.html
+++ b/mo/web/templates/org_user.html
@@ -28,6 +28,14 @@
</button>
</form>
{% endif %}
+{% if upgrade_form %}
+<form method=POST class='btn-group' onsubmit='return confirm("Změnit účastnický účet na organizátorský?");'>
+ {{ upgrade_form.csrf_token }}
+ <button class="btn btn-default" type='submit' name='upgrade' value='yes'>
+ Změnit na organizátora
+ </button>
+</form>
+{% endif %}
{% if g.user.is_admin %}
<a class="btn btn-default" href="{{ log_url('user', user.user_id) }}">Historie</a>
{% endif %}
diff --git a/static/mo.css b/static/mo.css
index 7dcf41627fcf49589e0437c14bdfd8833da9d03f..6796adad0767a50499d93370f949bafc347ac262 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -70,6 +70,12 @@ span.unknown {
color: red;
}
+.has-tip {
+ text-decoration: underline dashed;
+}
+
+/* Tables */
+
table.data {
border-collapse: collapse;
margin-top: 2ex;
@@ -388,6 +394,8 @@ div.alert + div.alert {
max-height: 100vh;
}
+/* User messages */
+
div.message {
padding: 5px 10px;
margin-bottom: 5px;