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;