diff --git a/db/db.ddl b/db/db.ddl index b20d86522fb9feecd227c592cc519bcf22335de7..aa5d3d1b73bc5bc10c05a98b676667a0b836f075 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -355,3 +355,42 @@ CREATE TABLE reg_requests ( user_id int DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE, client varchar(255) NOT NULL -- kdo si registraci vyžádal ); + +-- Statistiky + +-- Pro každý region spočítáme všechna podřízená místa +CREATE VIEW region_descendants AS + WITH RECURSIVE descendant_regions(place_id, descendant) AS ( + SELECT place_id, place_id FROM places + UNION SELECT r.place_id, p.place_id + FROM descendant_regions r, places p + WHERE p.parent = r.descendant + ) SELECT place_id AS region, descendant FROM descendant_regions; + +-- Pro každou trojici (kolo, region, stav soutěže) spočítáme soutěže. +CREATE VIEW region_contest_stats AS + SELECT c.round_id, rd.region, c.state, count(*) AS count + FROM contests c + JOIN region_descendants rd ON rd.descendant = c.place_id + WHERE rd.region <> c.place_id + GROUP BY c.round_id, rd.region, c.state; + +-- Pro každou trojici (kolo, region, stav účasti) spočítáme účastníky. +-- Pokud se to ukáže být příliš pomalé, přejdeme na materializovaná views. +CREATE VIEW region_participant_stats AS + SELECT c.round_id, rd.region, p.state, count(*) AS count + FROM participations p + JOIN contests c USING(contest_id) + JOIN region_descendants rd ON rd.descendant = c.place_id + GROUP BY c.round_id, rd.region, p.state; + +-- Pro každou trojici (kolo, region, úloha) spočítáme řešení. +CREATE VIEW region_task_stats AS + SELECT r.round_id, rd.region, t.task_id, count(*) AS count + FROM rounds r + JOIN contests c USING(round_id) + JOIN participations p USING(contest_id) + JOIN tasks t USING(round_id) + JOIN solutions s USING(user_id, task_id) + JOIN region_descendants rd ON rd.descendant = c.place_id + GROUP BY r.round_id, rd.region, t.task_id; diff --git a/db/upgrade-20210924.sql b/db/upgrade-20210924.sql new file mode 100644 index 0000000000000000000000000000000000000000..c0418ba5f35f60493a881ec5a09442303f88475a --- /dev/null +++ b/db/upgrade-20210924.sql @@ -0,0 +1,38 @@ +SET ROLE 'mo_osmo'; + +-- Pro každý region spočítáme všechna podřízená místa +CREATE VIEW region_descendants AS + WITH RECURSIVE descendant_regions(place_id, descendant) AS ( + SELECT place_id, place_id FROM places + UNION SELECT r.place_id, p.place_id + FROM descendant_regions r, places p + WHERE p.parent = r.descendant + ) SELECT place_id AS region, descendant FROM descendant_regions; + +-- Pro každou trojici (kolo, region, stav soutěže) spočítáme soutěže. +CREATE VIEW region_contest_stats AS + SELECT c.round_id, rd.region, c.state, count(*) AS count + FROM contests c + JOIN region_descendants rd ON rd.descendant = c.place_id + WHERE rd.region <> c.place_id + GROUP BY c.round_id, rd.region, c.state; + +-- Pro každou trojici (kolo, region, stav účasti) spočítáme účastníky. +-- Pokud se to ukáže být příliš pomalé, přejdeme na materializovaná views. +CREATE VIEW region_participant_stats AS + SELECT c.round_id, rd.region, p.state, count(*) AS count + FROM participations p + JOIN contests c USING(contest_id) + JOIN region_descendants rd ON rd.descendant = c.place_id + GROUP BY c.round_id, rd.region, p.state; + +-- Pro každou trojici (kolo, region, úloha) spočítáme řešení. +CREATE VIEW region_task_stats AS + SELECT r.round_id, rd.region, t.task_id, count(*) AS count + FROM rounds r + JOIN contests c USING(round_id) + JOIN participations p USING(contest_id) + JOIN tasks t USING(round_id) + JOIN solutions s USING(user_id, task_id) + JOIN region_descendants rd ON rd.descendant = c.place_id + GROUP BY r.round_id, rd.region, t.task_id; diff --git a/etc/config.py.example b/etc/config.py.example index f7db910c8d24c676a30bba7a76e80c983ab6f1db..af31c885e4b8e54e644833d85064433a921fb6df 100644 --- a/etc/config.py.example +++ b/etc/config.py.example @@ -53,3 +53,6 @@ REG_MAX_PER_MINUTE = 10 # Jak dlouho vydrží tokeny používané při registraci a změnách e-mailu [min] REG_TOKEN_VALIDITY = 10 + +# Aktuální ročník MO +CURRENT_YEAR = 71 diff --git a/mo/__init__.py b/mo/__init__.py index 98d16b9ad30321087b30790a71fe913ae7eec036..d98ba045b8e901ab953bcc40df33a46e096b8c94 100644 --- a/mo/__init__.py +++ b/mo/__init__.py @@ -2,9 +2,6 @@ import datetime -# Aktuální ročník -current_year = 71 - # Referenční čas nastavovaný v initu requestu (web) nebo při volání skriptu now: datetime.datetime diff --git a/mo/db.py b/mo/db.py index 000d01ca6fd196edc465f7d3a1afa00177514c47..8794e71534d095c6e15a1ebf24a19adde424da16 100644 --- a/mo/db.py +++ b/mo/db.py @@ -11,8 +11,9 @@ from sqlalchemy import \ text, func, \ create_engine, inspect, select from sqlalchemy.engine import Engine -from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload +from sqlalchemy.orm import relationship, sessionmaker, Session, class_mapper, joinedload, aliased from sqlalchemy.orm.attributes import get_history +from sqlalchemy.orm.query import Query from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql.expression import CTE @@ -705,6 +706,50 @@ class RegRequest(Base): user = relationship('User') +class RegionDescendant(Base): + __tablename__ = 'region_descendants' + + region = Column(Integer, ForeignKey('places.place_id'), primary_key=True) + descendant = Column(Integer, ForeignKey('places.place_id'), primary_key=True) + + +class RegionContestStat(Base): + __tablename__ = 'region_contest_stats' + + round_id = Column(Integer, ForeignKey('rounds.round_id'), primary_key=True) + region = Column(Integer, ForeignKey('places.place_id'), primary_key=True) + state = Column(Enum(RoundState, name='round_state'), primary_key=True) + count = Column(Integer, nullable=False) + + round = relationship('Round') + region_place = relationship('Place', primaryjoin='RegionContestStat.region == Place.place_id', remote_side='Place.place_id') + + +class RegionParticipantStat(Base): + __tablename__ = 'region_participant_stats' + + round_id = Column(Integer, ForeignKey('rounds.round_id'), primary_key=True) + region = Column(Integer, ForeignKey('places.place_id'), primary_key=True) + state = Column(Enum(PartState, name='part_state'), primary_key=True) + count = Column(Integer, nullable=False) + + round = relationship('Round') + region_place = relationship('Place', primaryjoin='RegionParticipantStat.region == Place.place_id', remote_side='Place.place_id') + + +class RegionTaskStat(Base): + __tablename__ = 'region_task_stats' + + round_id = Column(Integer, ForeignKey('rounds.round_id'), primary_key=True) + region = Column(Integer, ForeignKey('places.place_id'), primary_key=True) + task_id = Column(Integer, ForeignKey('tasks.task_id'), primary_key=True) + count = Column(Integer, nullable=False) + + round = relationship('Round') + region_place = relationship('Place', primaryjoin='RegionTaskStat.region == Place.place_id', remote_side='Place.place_id') + task = relationship('Task') + + _engine: Optional[Engine] = None _session: Optional[Session] = None flask_db: Any = None @@ -731,22 +776,24 @@ def get_seqs() -> List[int]: return [seq for (seq,) in get_session().query(Round.seq).distinct()] -def get_place_parents(place: Place) -> List[Place]: +def get_place_ancestors(place: Place) -> List[Place]: """Low-level funkce pro zjištění předků místa. - Obvykle voláme mo.rights.Gatekeeper.get_parents(), které kešuje.""" + Obvykle voláme mo.rights.Gatekeeper.get_ancestors(), které kešuje. + Pozor, výsledkem není plnohodnotný objekt Place, ale jen named tuple. + """ sess = get_session() topq = (sess.query(Place) .filter(Place.place_id == place.place_id) - .cte('parents', recursive=True)) + .cte('ancestors', recursive=True)) botq = (sess.query(Place) .join(topq, Place.place_id == topq.c.parent)) recq = topq.union(botq) - return sess.query(recq).all() + return sorted(sess.query(recq).all(), key=lambda p: p.level) def place_descendant_cte(place: Place, max_level: Optional[int] = None) -> CTE: @@ -776,6 +823,15 @@ def get_place_descendants(place: Place, min_level: Optional[int] = None, max_lev return q.all() +def filter_place_nth_parent(query: Query, place_attr: Any, n: int, parent_id: int) -> Query: + assert n >= 0 + for _ in range(n): + pp = aliased(Place) + query = query.join(pp, pp.place_id == place_attr) + place_attr = pp.parent + return query.filter(place_attr == parent_id) + + def get_object_changes(obj): """ Given a model instance, returns dict of pending changes waiting for database flush/commit. diff --git a/mo/imports.py b/mo/imports.py index 005f62f029da3f0a8840b9c16d462352ce53b38a..8e894710bdca8d88b1a5d845b368d1d40a2c33e0 100644 --- a/mo/imports.py +++ b/mo/imports.py @@ -5,8 +5,9 @@ import io import re from sqlalchemy import and_ from sqlalchemy.orm import joinedload, Query -from typing import List, Optional, Any, Dict, Type, Union +from typing import List, Optional, Any, Dict, Type, Union, Set +import mo.config as config import mo.csv from mo.csv import FileFormat, MissingHeaderError import mo.db as db @@ -56,6 +57,7 @@ class Import: 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 @@ -230,19 +232,30 @@ class Import: self.cnt_new_participations += 1 return pion + def place_is_allowed(self, place: db.Place) -> bool: + if self.contest is not None and self.contest.place_id != place.place_id: + return False + if self.only_region is not None and not self.gatekeeper.is_ancestor_of(self.only_region, place): + return False + return True + def obtain_contest(self, oblast: Optional[db.Place], allow_none: bool = False): + assert self.round + 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: contest = self.contest - if oblast is not None and oblast.place_id != contest.place.place_id: - return self.error('Oblast neodpovídá té, do které se importuje') else: + # Zde mluvíme o oblastech, místo abychom používali place_levels, + # protože sloupec má ve jménu oblast a také je potřeba rozlišovat školu + # účastníka a školu jako oblast. if oblast is None: if not allow_none: - self.error('Je nutné uvést ' + self.round.get_level().name) + 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() if contest is None: - return self.error('V ' + self.round.get_level().name_locative("uvedeném", "uvedené", "uvedeném") + ' toto kolo neprobíhá') + return self.error('V uvedené oblasti toto kolo neprobíhá') return contest @@ -280,6 +293,8 @@ class Import: args.append(f'round=#{self.round.round_id}') if self.contest is not None: args.append(f'contest=#{self.contest.contest_id}') + if self.only_region is not None: + args.append(f'region=#{self.only_region.place_id}') if self.task is not None: args.append(f'task=#{self.task.task_id}') @@ -300,17 +315,21 @@ class Import: args.append(f'{key}={val}') logger.info('Import: Hotovo (%s)', " ".join(args)) + details = self.log_details.copy() + if self.only_region: + details['region'] = self.only_region.place_id + if self.contest is not None: mo.util.log( type=db.LogType.contest, what=self.contest.contest_id, - details=self.log_details, + details=details, ) elif self.round is not None: mo.util.log( type=db.LogType.round, what=self.round.round_id, - details=self.log_details, + details=details, ) else: assert False @@ -449,7 +468,7 @@ class ContestImport(Import): if user is None: return - part = self.find_or_create_participant(user, mo.current_year, school_place.place_id if school_place else None, rok_naroz, rocnik) + part = self.find_or_create_participant(user, config.CURRENT_YEAR, school_place.place_id if school_place else None, rok_naroz, rocnik) if part is None: return @@ -587,6 +606,9 @@ class PointsImport(Import): query = query.filter(db.Participation.contest_id == self.contest.master_contest_id) else: contest_query = sess.query(db.Contest.master_contest_id).filter_by(round=self.round) + if self.only_region: + assert self.round + contest_query = db.filter_place_nth_parent(contest_query, db.Contest.place_id, self.round.level - self.only_region.level, self.only_region.place_id) query = query.filter(db.Participation.contest_id.in_(contest_query.subquery())) return query @@ -614,7 +636,13 @@ class PointsImport(Import): query = self._pion_sol_query().filter(db.Participation.user_id == user_id) pion_sols = query.all() if not pion_sols: - return self.error('Soutěžící nenalezen v tomto kole') + if self.contest is not None: + msg = self.round.get_level().name_locative('tomto', 'této', 'tomto') + elif self.only_region is not None: + msg = self.only_region.get_level().name_locative('tomto', 'této', 'tomto') + else: + msg = 'tomto kole' + return self.error(f'Soutěžící nenalezen v {msg}') elif len(pion_sols) > 1: return self.error('Soutěžící v tomto kole soutěží vícekrát, neumím zpracovat') pion, sol = pion_sols[0] @@ -624,10 +652,6 @@ class PointsImport(Import): else: contest = sess.query(db.Contest).filter_by(round=self.round, master_contest_id=pion.contest_id).one() - if self.contest is not None: - if contest != self.contest: - return self.error('Soutěžící nesoutěží v ' + self.round.get_level().name_locative('tomto', 'této', 'tomto')) - rights = self.gatekeeper.rights_for_contest(contest) if not rights.can_edit_points(): return self.error('Nemáte právo na úpravu bodů') @@ -708,6 +732,7 @@ def create_import(user: db.User, 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 @@ -725,6 +750,7 @@ def create_import(user: db.User, 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 diff --git a/mo/jobs/submit.py b/mo/jobs/submit.py index 129d3302f4931958f6d081fd55ee03ea1ab85f16..c6e52c2f849414360b90fc6e0fb123993ec510eb 100644 --- a/mo/jobs/submit.py +++ b/mo/jobs/submit.py @@ -5,6 +5,7 @@ import os import re import shutil from sqlalchemy import and_ +from sqlalchemy.orm import joinedload from tempfile import NamedTemporaryFile from typing import List, Optional import unicodedata @@ -84,6 +85,7 @@ def handle_download_submits(the_job: TheJob): def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, for_user: db.User, only_contest: Optional[db.Contest], only_site: Optional[db.Place], + only_region: Optional[db.Place], only_task: Optional[db.Task]): the_job = TheJob() job = the_job.create(db.JobType.upload_feedback, for_user) @@ -92,6 +94,7 @@ def schedule_upload_feedback(round: db.Round, tmp_file: str, description: str, f 'round_id': round.round_id, 'only_contest_id': only_contest.contest_id if only_contest is not None else None, 'only_site_id': only_site.place_id if only_site is not None else None, + 'only_region_id': only_region.place_id if only_region is not None else None, 'only_task_id': only_task.task_id if only_task is not None else None, } job.in_file = the_job.attach_file(tmp_file, '.zip') @@ -165,12 +168,19 @@ def handle_upload_feedback(the_job: TheJob): round_id: int = in_json['round_id'] # type: ignore only_contest_id: Optional[int] = in_json['only_contest_id'] # type: ignore only_site_id: Optional[int] = in_json['only_site_id'] # type: ignore + only_region_id: Optional[int] = in_json['only_region_id'] # type: ignore only_task_id: Optional[int] = in_json['only_task_id'] # type: ignore sess = db.get_session() round = sess.query(db.Round).get(round_id) assert round is not None + if only_region_id is not None: + only_region = sess.query(db.Place).get(only_region_id) + assert only_region is not None + else: + only_region = None + files: List[UploadFeedback] = [] def parse_zip(in_path: str): @@ -219,6 +229,7 @@ def handle_upload_feedback(the_job: TheJob): .join(db.Contest, db.Contest.master_contest_id == db.Participation.contest_id) .filter(db.Contest.round == round) .filter(db.Participation.user_id.in_(user_dict.keys())) + .options(joinedload(db.Contest.place)) .all()) user_rights = {} @@ -237,6 +248,8 @@ def handle_upload_feedback(the_job: TheJob): the_job.error(f'{f.file_name}: Účastník leží mimo vybranou soutěž') elif only_site_id is not None and site_id_dict[f.user_id] != only_site_id: the_job.error(f'{f.file_name}: Účastník leží mimo vybrané soutěžní místo') + elif only_region is not None and not the_job.gatekeeper.is_ancestor_of(only_region, contest_dict[f.user_id].place): + the_job.error(f'{f.file_name}: Účastník leží mimo vybraný region') elif not user_rights[f.user_id]: the_job.error(f'{f.file_name}: K tomuto účastníkovi nemáte dostatečná oprávnění') diff --git a/mo/rights.py b/mo/rights.py index e7873c327954b593fdfcdb7d1fbe96222f3ff13b..c8d549a3d6133702f9917217f795ff441dd436b0 100644 --- a/mo/rights.py +++ b/mo/rights.py @@ -198,31 +198,6 @@ class Rights: return self.have_right(Right.edit_orgs) return self.have_right(Right.edit_users) - # Interní rozhodovaní o dostupnosti zadání - - def _check_view_statement(self, round: db.Round): - if round.tasks_file is None: - return False - - if self.have_right(Right.manage_round): - # Správce kola může vždy všechno - return True - - # Pokud už soutěž skončila, přístup k zadání má každý org. - # XXX: Rozhodujeme podle stavu kola, nikoliv soutěže! - if round.state in [db.RoundState.grading, db.RoundState.closed]: - return True - - # Od stanoveného času vidí zadání orgové s právem view_statement. - if (self.have_right(Right.view_statement) - and round.state != db.RoundState.preparing - and round.pr_tasks_start is not None - and mo.now >= round.pr_tasks_start): - return True - - # Ve zbylých případech jsme konzervativní a zadání neukazujeme - return False - class RoundRights(Rights): """Práva ke kolu.""" @@ -237,8 +212,11 @@ class RoundRights(Rights): # Metody offer_* testují, zda se má v UI nabízet příslušná funkce. # Skutečnou kontrolu práv dělá až implementace funkce podle stavu soutěže. + def _get_state(self) -> db.RoundState: + return self.round.state + def _is_active(self) -> bool: - return self.round.state not in [db.RoundState.preparing, db.RoundState.closed] + return self._get_state() not in [db.RoundState.preparing, db.RoundState.closed] def offer_upload_solutions(self) -> bool: return (self.have_right(Right.upload_submits) @@ -252,11 +230,47 @@ class RoundRights(Rights): return (self.have_right(Right.manage_contest) or (self.have_right(Right.edit_points) and self._is_active())) - def can_view_statement(self) -> bool: - return self._check_view_statement(self.round) + def can_upload_solutions(self) -> bool: + return (self.have_right(Right.upload_submits) + or self.have_right(Right.upload_solutions) and self._get_state() == db.RoundState.running) + + def can_upload_feedback(self) -> bool: + return (self.have_right(Right.upload_submits) + or self.have_right(Right.upload_feedback) and self._get_state() == db.RoundState.grading) + + def can_edit_points(self) -> bool: + return (self.have_right(Right.edit_points) and self._get_state() == db.RoundState.grading + or self.have_right(Right.manage_contest)) + def can_create_solutions(self) -> bool: + return self.can_upload_solutions() or self.can_upload_feedback() -class ContestRights(Rights): + def can_view_statement(self): + round = self.round + if round.tasks_file is None: + return False + + if self.have_right(Right.manage_round): + # Správce kola může vždy všechno + return True + + # Pokud už soutěž skončila, přístup k zadání má každý org. + # XXX: Rozhodujeme podle stavu kola, nikoliv soutěže! + if round.state in [db.RoundState.grading, db.RoundState.closed]: + return True + + # Od stanoveného času vidí zadání orgové s právem view_statement. + if (self.have_right(Right.view_statement) + and round.state != db.RoundState.preparing + and round.pr_tasks_start is not None + and mo.now >= round.pr_tasks_start): + return True + + # Ve zbylých případech jsme konzervativní a zadání neukazujeme + return False + + +class ContestRights(RoundRights): """Práva k soutěži.""" contest: db.Contest @@ -264,22 +278,10 @@ class ContestRights(Rights): def __repr__(self): ros = " ".join([r.role.name for r in self.user_roles]) ris = " ".join([r.name for r in self.rights]) - return f"ContestRights(uid={self.user.user_id} is_admin={self.user.is_admin} contest=#{self.contest.contest_id} roles=<{ros}> rights=<{ris}>)" - - def can_upload_solutions(self) -> bool: - return (self.have_right(Right.upload_submits) - or self.have_right(Right.upload_solutions) and self.contest.state == db.RoundState.running) - - def can_upload_feedback(self) -> bool: - return (self.have_right(Right.upload_submits) - or self.have_right(Right.upload_feedback) and self.contest.state == db.RoundState.grading) + return f"ContestRights(uid={self.user.user_id} is_admin={self.user.is_admin} round=#{self.round.round_id} contest=#{self.contest.contest_id} roles=<{ros}> rights=<{ris}>)" - def can_edit_points(self) -> bool: - return (self.have_right(Right.edit_points) and self.contest.state == db.RoundState.grading - or self.have_right(Right.manage_contest)) - - def can_view_statement(self) -> bool: - return self._check_view_statement(self.contest.round) + def _get_state(self) -> db.RoundState: + return self.contest.state class Gatekeeper: @@ -299,12 +301,17 @@ class Gatekeeper: self.parent_cache = {} self.rights_cache = {} - def get_parents(self, place: db.Place) -> List[db.Place]: + def get_ancestors(self, place: db.Place) -> List[db.Place]: pid = place.place_id if pid not in self.parent_cache: - self.parent_cache[pid] = db.get_place_parents(place) + self.parent_cache[pid] = db.get_place_ancestors(place) return self.parent_cache[pid] + def is_ancestor_of(self, ancestor: db.Place, of: db.Place) -> bool: + ancestors = self.get_ancestors(of) + parent_ids = set(p.place_id for p in ancestors) + return ancestor.place_id in parent_ids + def rights_for( self, place: Optional[db.Place] = None, year: Optional[int] = None, cat: Optional[str] = None, seq: Optional[int] = None, @@ -336,7 +343,7 @@ class Gatekeeper: for role in self.roles: try_role(role, None) else: - for at in self.get_parents(place): + for at in self.get_ancestors(place): for role in self.roles: try_role(role, at) @@ -347,9 +354,11 @@ class Gatekeeper: """Posbírá role a práva, ale ignoruje omezení rolí na místa a soutěže. Hodí se pro práva k editaci uživatelů apod.""" return self.rights_for() - def rights_for_round(self, round: db.Round, any_place: bool) -> RoundRights: + def rights_for_round(self, round: db.Round, any_place: bool = False, for_place: Optional[db.Place] = None) -> RoundRights: if any_place: place = None + elif for_place: + place = for_place else: place = db.get_root_place() rights = RoundRights() @@ -364,6 +373,7 @@ class Gatekeeper: def rights_for_contest(self, contest: db.Contest, site: Optional[db.Place] = None) -> ContestRights: rights = ContestRights() + rights.round = contest.round rights.contest = contest rights._clone_from(self.rights_for( place=site or contest.place, diff --git a/mo/web/acct.py b/mo/web/acct.py index cd3bc74e15d377bc877887ec678a6f6a2fbcab9d..a07888d3dd7b1682c5e3bbf0102374e26097384a 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -120,7 +120,7 @@ def user_settings(): if g.user.is_org or g.user.is_admin: pant = None else: - pant = sess.query(db.Participant).get((g.user.user_id, mo.current_year)) + pant = sess.query(db.Participant).get((g.user.user_id, config.CURRENT_YEAR)) return render_template('settings.html', user=g.user, pant=pant, roles=roles, roles_by_type=mo.rights.roles_by_type) diff --git a/mo/web/jinja.py b/mo/web/jinja.py index c81543bad49d165a1fd15c3582881a363bd5fd18..04cb8b0c66dc5aeac382aa43f86c59639a642749 100644 --- a/mo/web/jinja.py +++ b/mo/web/jinja.py @@ -9,9 +9,9 @@ import urllib.parse import mo.config as config import mo.db as db +from mo.rights import Right import mo.util_format as util_format from mo.web import app -from mo.web.org_contest import contest_breadcrumbs from mo.web.org_place import place_breadcrumbs from mo.web.util import user_html_flags @@ -47,10 +47,10 @@ app.jinja_env.globals.update(JobState=db.JobState) # Další typy: app.jinja_env.globals.update(Markup=Markup) +app.jinja_env.globals.update(Right=Right) # Vlastní pomocné funkce -app.jinja_env.globals.update(contest_breadcrumbs=contest_breadcrumbs) app.jinja_env.globals.update(place_breadcrumbs=place_breadcrumbs) # Funkce asset_url se přidává v mo.ext.assets @@ -60,6 +60,7 @@ def user_link(u: db.User) -> Markup: return Markup('<a href="{url}">{name}{test}</a>').format(url=user_url(u), name=u.full_name(), test=" (test)" if u.is_test else "") +@app.template_filter() def user_url(u: db.User) -> str: if u.is_admin or u.is_org: return url_for('org_org', id=u.user_id) @@ -69,7 +70,7 @@ def user_url(u: db.User) -> str: @app.template_filter() def pion_link(u: db.User, contest_id: int) -> Markup: - url = url_for('org_contest_user', contest_id=contest_id, user_id=u.user_id) + url = url_for('org_contest_user', ct_id=contest_id, user_id=u.user_id) return Markup('<a href="{url}">{name}{test}</a>').format(url=url, name=u.full_name(), test=" (test)" if u.is_test else "") diff --git a/mo/web/org.py b/mo/web/org.py index aebbfae957f8f0677327553d3a9a4e20b7671015..4830309acbfad5967c549b10761912eb9e5d962b 100644 --- a/mo/web/org.py +++ b/mo/web/org.py @@ -1,8 +1,10 @@ +from dataclasses import dataclass, field from flask import render_template, redirect, url_for, request, flash, g from sqlalchemy import and_, or_ from sqlalchemy.orm import aliased, joinedload -from typing import List, Set, Dict +from typing import List, Set, Optional +import mo.config as config import mo.db as db import mo.rights import mo.users @@ -11,6 +13,15 @@ from mo.web.jinja import user_url from mo.web.table import Table, Row, Column +@dataclass +class OrgOverview: + round: db.Round + place: db.Place + contest: Optional[db.Contest] + role_set: Set[db.RoleType] = field(default_factory=set) + role_list: List[db.RoleType] = field(default_factory=list) + + @app.route('/org/') def org_index(): if 'place' in request.args: @@ -32,36 +43,33 @@ def org_index(): flash('ID uživatele musí být číslo', 'danger') sess = db.get_session() - ctr = (sess.query(db.Contest, db.UserRole) - .select_from(db.UserRole, db.Round, db.Contest) - .filter(and_(db.UserRole.user_id == g.user.user_id, - or_(db.UserRole.category == None, db.UserRole.category == db.Round.category), - or_(db.UserRole.year == None, db.UserRole.year == db.Round.year), - or_(db.UserRole.seq == None, db.UserRole.seq == db.Round.seq), - db.Round.year == mo.current_year, - db.Contest.round_id == db.Round.round_id, - db.Contest.place_id == db.UserRole.place_id)) - .options(joinedload(db.Contest.place)) + rcu = (sess.query(db.Round, db.Contest, db.UserRole) + .select_from(db.UserRole) + .join(db.Place) + .join(db.Round, and_(db.UserRole.user_id == g.user.user_id, + or_(db.UserRole.category == None, db.UserRole.category == db.Round.category), + or_(db.UserRole.year == None, db.UserRole.year == db.Round.year), + or_(db.UserRole.seq == None, db.UserRole.seq == db.Round.seq), + db.Place.level <= db.Round.level)) + .outerjoin(db.Contest, and_(db.Contest.round_id == db.Round.round_id, db.Contest.place_id == db.UserRole.place_id)) + .filter(db.Round.year == config.CURRENT_YEAR) + .options(joinedload(db.UserRole.place)) .order_by(db.Round.level, db.Round.category, db.Round.seq, db.Round.part, db.Contest.place_id, db.Contest.contest_id) .all()) - # Pokud máme pro jednu soutěž více rolí, zkombinujeme je - contests: List[db.Contest] = [] - contest_role_sets: Dict[db.Contest, Set[db.RoleType]] = {} - for ct, ur in ctr: - if len(contests) == 0 or contests[-1] != ct: - contests.append(ct) - contest_role_sets[ct.contest_id] = set() - contest_role_sets[ct.contest_id].add(ur.role) - - # Role pro každou soutěž setřídíme podle důležitosti - contest_roles: Dict[db.Contest, List[db.RoleType]] = { - ct_id: sorted(list(contest_role_sets[ct_id]), key=lambda r: mo.rights.role_order_by_type[r]) - for ct_id in contest_role_sets.keys() - } - - return render_template('org_index.html', contests=contests, contest_roles=contest_roles, role_type_names=db.role_type_names) + overview: List[OrgOverview] = [] + for r, ct, ur in rcu: + o = overview[-1] if overview else None + if not (o and o.round == r and o.place == ur.place): + o = OrgOverview(round=r, place=ur.place, contest=ct) + overview.append(o) + o.role_set.add(ur.role) + + for o in overview: + o.role_list = sorted(o.role_set, key=lambda r: mo.rights.role_order_by_type[r]) + + return render_template('org_index.html', overview=overview, role_type_names=db.role_type_names) school_export_columns = ( diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index 0a17429c8203b2b150d4357ebb562b8ca3db7910..675e5f490f787fd03e9850d3d8b1b1f39c1c9389 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from flask import render_template, g, redirect, url_for, flash, request from flask_wtf import FlaskForm import flask_wtf.file @@ -8,10 +7,13 @@ 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 -from typing import Any, List, Tuple, Optional, Sequence, Dict +import sqlalchemy.sql.schema +from typing import Any, List, Tuple, Optional, Dict import urllib.parse import werkzeug.exceptions import wtforms +import wtforms.validators as validators +from wtforms.widgets.html5 import NumberInput import mo from mo.csv import FileFormat @@ -19,33 +21,190 @@ import mo.config as config import mo.db as db from mo.imports import ImportType, create_import import mo.jobs.submit -from mo.rights import Right, ContestRights +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 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, cell_email_link_flags -import wtforms.validators as validators -from wtforms.widgets.html5 import NumberInput +from mo.web.table import CellCheckbox, Table, Row, Column, cell_pion_link, cell_place_link, cell_email_link_flags -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 Context: + # Kolo máme vždy + round: db.Round + master_round: db.Round + + # Vypočtená práva: Může být implementováno i jako ContestRights + rights: RoundRights + + # Můžeme mít vybranou soutěž a místo + # Pro nedělená kola platí contest == master_contest. + # Operace s účastníky by měly probíhat vždy přes master_contest. + contest: Optional[db.Contest] = None + master_contest: Optional[db.Contest] = None + site: Optional[db.Place] = None + + # Můžeme se omezit na soutěže v dané oblasti + hier_place: Optional[db.Place] = None + + # Účastník a úloha + pion: Optional[db.Participation] = None + user: Optional[db.User] = None + task: Optional[db.Task] = None + + # IDčka, jak je předáváme do URL + round_id: Optional[int] = None + ct_id: Optional[int] = None + site_id: Optional[int] = None + hier_id: Optional[int] = None + user_id: Optional[int] = None + task_id: Optional[int] = None + + def url_for(self, endpoint: str, **kwargs): + a = {} + round_id = kwargs.get('round_id', self.round_id) + ct_id = kwargs.get('ct_id', self.ct_id) + if ct_id is not None: + a['ct_id'] = ct_id + else: + assert round_id is not None + a['round_id'] = round_id + for arg in ('site_id', 'hier_id', 'user_id', 'task_id'): + val = getattr(self, arg) + if val is not None: + a[arg] = val + for arg, val in kwargs.items(): + a[arg] = val + return url_for(endpoint, **a) + + def url_home(self): + if self.ct_id: + return url_for('org_contest', ct_id=self.ct_id) + else: + return url_for('org_round', round_id=self.round_id, hier_id=self.hier_id) + + def breadcrumbs(self, table: bool = False, action: Optional[str] = None) -> Markup: + elements = [(url_for('org_rounds'), 'Soutěže')] + elements.append((url_for('org_round', round_id=self.round_id), self.round.round_code())) + if self.hier_place: + parents = g.gatekeeper.get_ancestors(self.hier_place) + for p in parents[1:]: + elements.append((url_for('org_round', round_id=self.round_id, hier_id=p.place_id), p.name or '???')) + if self.contest: + if self.round.level >= 2: + parents = g.gatekeeper.get_ancestors(self.contest.place) + for i in range(1, len(parents) - 1): + p = parents[i] + if p.level >= 3: + break + elements.append((url_for('org_round', round_id=self.round_id, hier_id=p.place_id), db.Place.get_code(p))) + elements.append((url_for('org_contest', ct_id=self.ct_id), self.contest.place.name or '???')) + if self.site: + elements.append((url_for('org_contest', ct_id=self.ct_id, site_id=self.site_id), f"soutěžní místo {self.site.name}")) + if self.task: + elements.append(( + url_for('org_contest_task', ct_id=self.ct_id, site_id=self.site_id, task_id=self.task_id) if self.contest + else url_for('org_round_task_edit', round_id=self.round_id, task_id=self.task_id), + f"{self.task.code} {self.task.name}" + )) + if self.user: + elements.append((url_for('org_contest_user', ct_id=self.ct_id, user_id=self.user_id), self.user.full_name())) + if table: + if self.contest: + elements.append((url_for('org_generic_list', ct_id=self.ct_id, site=self.site_id), "Seznam účastníků")) + else: + elements.append((url_for('org_generic_list', round_id=self.round_id), "Seznam účastníků")) + if action: + elements.append(('', action)) + + return Markup( + "\n".join([f"<li><a href='{url}'>{name}</a>" for url, name in elements[:-1]]) + + "<li>" + elements[-1][1] + ) + + +def get_context(round_id: Optional[int] = None, + ct_id: Optional[int] = None, + site_id: Optional[int] = None, + hier_id: Optional[int] = None, + user_id: Optional[int] = None, + task_id: Optional[int] = None, + right_needed: Optional[Right] = None, + ) -> Context: + + ctx = Context() + ctx.round_id = round_id + ctx.ct_id = ct_id + ctx.site_id = site_id + ctx.hier_id = hier_id + ctx.user_id = user_id + ctx.task_id = task_id + + sess = db.get_session() + + if site_id is not None: + assert ct_id is not None + ctx.site = db.get_session().query(db.Place).get(site_id) + if not ctx.site: + raise werkzeug.exceptions.NotFound() + + if hier_id is not None: + assert ct_id is None + ctx.hier_place = db.get_session().query(db.Place).get(hier_id) + if not ctx.hier_place: + raise werkzeug.exceptions.NotFound() + + if ct_id is not None: + ctx.contest = (db.get_session().query(db.Contest) + .options(joinedload(db.Contest.place), + joinedload(db.Contest.round), + joinedload(db.Contest.master).joinedload(db.Contest.round)) + .get(ct_id)) + if not ctx.contest: + raise werkzeug.exceptions.NotFound() + ctx.master_contest = ctx.contest.master + ctx.round, ctx.master_round = ctx.contest.round, ctx.master_contest.round + if round_id is not None and ctx.round.round_id != round_id: + raise werkzeug.exceptions.NotFound() + ctx.round_id = ctx.round.round_id + ctx.rights = g.gatekeeper.rights_for_contest(ctx.contest, ctx.site) + else: + ctx.round = sess.query(db.Round).options(joinedload(db.Round.master)).get(round_id) + if not ctx.round: + raise werkzeug.exceptions.NotFound() + if hier_id is not None and ctx.hier_place.level > ctx.round.level: + raise werkzeug.exceptions.NotFound() + ctx.master_round = ctx.round.master + ctx.rights = g.gatekeeper.rights_for_round(ctx.round, for_place=ctx.hier_place) + + # Zkontrolujeme, zda se účastník opravdu účastní soutěže + if user_id is not None: + assert ctx.master_contest is not None + ctx.pion = (sess.query(db.Participation) + .filter_by(user_id=user_id, contest_id=ctx.master_contest.contest_id) + .options(joinedload(db.Participation.place), + joinedload(db.Participation.user)) + .one_or_none()) + if not ctx.pion: + raise werkzeug.exceptions.NotFound() + ctx.user = ctx.pion.user + + # A zda soutěží na zadaném soutěžním místě, je-li určeno + if site_id is not None and site_id != ctx.pion.place_id: + raise werkzeug.exceptions.NotFound() + + # Najdeme úlohu a ověříme, že je součástí kola + if task_id is not None: + ctx.task = sess.query(db.Task).get(task_id) + if not ctx.task or ctx.task.round != ctx.round: + raise werkzeug.exceptions.NotFound() + + if not (right_needed is None or ctx.rights.have_right(right_needed)): + raise werkzeug.exceptions.Forbidden() + + return ctx class ParticipantsFilterForm(PagerForm): @@ -59,6 +218,7 @@ class ParticipantsFilterForm(PagerForm): download_csv = wtforms.SubmitField("↓ CSV") download_tsv = wtforms.SubmitField("↓ TSV") + class ParticipantsActionForm(FlaskForm): action_on = wtforms.RadioField( "Provést akci na", validators=[validators.DataRequired()], @@ -120,20 +280,7 @@ class ParticipantsActionForm(FlaskForm): flash('Data v checkboxech nelze převést na čísla, kontaktujte správce', 'danger') return False - # Check all participations if we can edit them ctants: List[Tuple[db.Participation, Any, Any]] = query.all() - for pion, _, _ in ctants: - u = pion.user - if self.action_on.data == 'checked' and u.user_id not in user_ids: - continue - rr = g.gatekeeper.rights_for_contest(pion.contest) - if not rr.have_right(Right.manage_contest): - flash( - f"Nemáte právo ke správě soutěže v kole {round.round_code_short()} {pion.contest.place.name_locative()} " - + f"(účastník {u.full_name()}). Žádná akce nebyla provedena.", 'danger' - ) - return False - count = 0 unchanged = 0 for pion, _, _ in ctants: @@ -154,6 +301,7 @@ class ParticipantsActionForm(FlaskForm): if self.set_participation_state.data: pion.state = self.participation_state.data elif self.set_participation_place.data: + assert participation_place pion.place_id = participation_place.place_id elif self.set_contest.data: pion.contest_id = contest.contest_id @@ -183,12 +331,14 @@ class ParticipantsActionForm(FlaskForm): 'success' ) elif self.set_participation_place.data: + assert participation_place flash( f'Nastaveno soutěžní místo {participation_place.name} ' + inflect_number(count, 'účastníkovi', 'účastníkům', 'účastníkům'), 'success' ) elif self.set_contest.data: + assert contest_place flash( inflect_number(count, 'účastník přesunut', 'účastníci přesunuti', 'účastníků přesunuto') + f' do soutěže {contest_place.name_locative()}', @@ -204,109 +354,19 @@ class ParticipantsActionForm(FlaskForm): return True -def get_contest(id: int) -> Tuple[db.Contest, db.Contest]: - """Vrací contest a master_contest pro zadané contest_id. - Pro nedělená kola platí contest == master_contest. - Operace s účastníky by měly probíhat vždy přes master_contest.""" - contest = (db.get_session().query(db.Contest) - .options(joinedload(db.Contest.place), - joinedload(db.Contest.round), - joinedload(db.Contest.master).joinedload(db.Contest.round)) - .get(id)) - if not contest: - raise werkzeug.exceptions.NotFound() - return contest, contest.master - - -def get_contest_rr(id: int, right_needed: Optional[Right] = None) -> Tuple[db.Contest, db.Contest, ContestRights]: - """Vrací contest, master_contest a ContestRights objekt pro zadané contest_id. - Pro nedělená kola platí contest == master_contest. - Operace s účastníky by měly probíhat vždy přes master_contest.""" - contest, master_contest = get_contest(id) - - rr = g.gatekeeper.rights_for_contest(contest) - - if not (right_needed is None or rr.have_right(right_needed)): - raise werkzeug.exceptions.Forbidden() - - return contest, master_contest, rr - - -def get_contest_site_rr(id: int, site_id: Optional[int], right_needed: Optional[Right] = None) -> Tuple[db.Contest, db.Contest, Optional[db.Place], ContestRights]: - """Vrací contest, master_contest, optional site a ContestRights objekt pro zadané contest_id a site_id. - Pro nedělená kola platí contest == master_contest. - Operace s účastníky by měly probíhat vždy přes master_contest.""" - if site_id is None: - contest, master_contest, rr = get_contest_rr(id, right_needed) - return contest, master_contest, None, rr - - contest, master_contest = get_contest(id) - site = db.get_session().query(db.Place).get(site_id) - if not site: - raise werkzeug.exceptions.NotFound() - - rr = g.gatekeeper.rights_for_contest(contest, site) - - if not (right_needed is None or rr.have_right(right_needed)): - raise werkzeug.exceptions.Forbidden() - - return contest, master_contest, site, rr - - -def contest_breadcrumbs( - round: Optional[db.Round] = None, contest: Optional[db.Contest] = None, - site: Optional[db.Place] = None, task: Optional[db.Task] = None, - user: Optional[db.User] = None, action: Optional[str] = None, - table: Optional[bool] = False -) -> Markup: - elements = [(url_for('org_rounds'), 'Soutěže')] - round_id = None - if round: - round_id = round.round_id - elements.append((url_for('org_round', id=round_id), round.round_code())) - ct_id = None - if contest: - ct_id = contest.contest_id - elements.append((url_for('org_contest', id=ct_id), contest.place.name)) - site_id = None - if site: - site_id = site.place_id - elements.append((url_for('org_contest', id=ct_id, site_id=site_id), f"soutěžní místo {site.name}")) - if task: - task_id = task.task_id - elements.append(( - url_for('org_contest_task', contest_id=ct_id, site_id=site_id, task_id=task_id) if ct_id - else url_for('org_round_task_edit', id=round_id, task_id=task_id), - f"{task.code} {task.name}" - )) - if user: - user_id = user.user_id - elements.append((url_for('org_contest_user', contest_id=ct_id, user_id=user_id), user.full_name())) - if table: - if contest: - elements.append((url_for('org_contest_list', id=ct_id, site=site_id), "Seznam účastníků")) - else: - elements.append((url_for('org_round_list', id=round_id), "Seznam účastníků")) - if action: - elements.append(('', action)) - - return Markup( - "\n".join([f"<li><a href='{url}'>{name}</a>" for url, name in elements[:-1]]) - + "<li>" + elements[-1][1] - ) - - -@app.route('/org/contest/c/<int:id>') -@app.route('/org/contest/c/<int:id>/site/<int:site_id>/') -def org_contest(id: int, site_id: Optional[int] = None): +@app.route('/org/contest/c/<int:ct_id>/') +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/') +def org_contest(ct_id: int, site_id: Optional[int] = None): sess = db.get_session() - contest, master_contest, site, rr = get_contest_site_rr(id, site_id, None) - round = contest.round - - tasks_subq = sess.query(db.Task.task_id).filter_by(round=contest.round) - pions_subq = sess.query(db.Participation.user_id).filter_by(contest=master_contest) - if site: - pions_subq = pions_subq.filter_by(place=site) + ctx = get_context(ct_id=ct_id, site_id=site_id) + contest = ctx.contest + assert contest + rights = ctx.rights + + tasks_subq = sess.query(db.Task.task_id).filter_by(round=ctx.round) + pions_subq = sess.query(db.Participation.user_id).filter_by(contest=ctx.master_contest) + if ctx.site: + pions_subq = pions_subq.filter_by(place=ctx.site) sol_counts_q = ( sess.query(db.Solution.task_id, func.count(db.Solution.task_id)) .filter( @@ -319,7 +379,7 @@ def org_contest(id: int, site_id: Optional[int] = None): for task_id, count in sol_counts_q.group_by(db.Solution.task_id).all(): sol_counts[task_id] = count - tasks = sess.query(db.Task).filter_by(round=round).all() + tasks = sess.query(db.Task).filter_by(round=ctx.round).all() tasks.sort(key=lambda t: t.code) for task in tasks: task.sol_count = sol_counts[task.task_id] if task.task_id in sol_counts else 0 @@ -330,7 +390,7 @@ def org_contest(id: int, site_id: Optional[int] = None): sess.query(db.Place, func.count('*')) .select_from(db.Participation).join(db.Place) .group_by(db.Place) - .filter(db.Participation.contest == master_contest).all() + .filter(db.Participation.contest == ctx.master_contest).all() ) group_contests = contest.get_group_contests(True) @@ -338,27 +398,50 @@ def org_contest(id: int, site_id: Optional[int] = None): return render_template( 'org_contest.html', - contest=contest, group_contests=group_contests, site=site, - rights=sorted(rr.rights, key=lambda r: r.name), - roles=[r.friendly_name() for r in rr.get_roles()], - can_manage=rr.have_right(Right.manage_contest), - can_upload=rr.can_upload_feedback(), - can_edit_points=rr.can_edit_points(), - can_create_solutions=rr.can_upload_feedback() or rr.can_upload_solutions(), - can_view_statement=rr.can_view_statement(), + ctx=ctx, rights=rights, + round=ctx.round, contest=contest, site=ctx.site, + group_contests=group_contests, + rights_list=sorted(rights.rights, key=lambda r: r.name), + roles=[r.friendly_name() for r in rights.get_roles()], tasks=tasks, places_counts=places_counts, ) -def generic_import(round: db.Round, master_round: db.Round, contest: Optional[db.Contest], master_contest: Optional[db.Contest]): - """Společná funkce pro importování do soutěží a kol""" +@app.route('/doc/import') +def doc_import(): + return render_template('doc_import.html') + + +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') + + +@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): + 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=master_round, contest=master_contest) + 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 @@ -369,10 +452,7 @@ def generic_import(round: db.Round, master_round: db.Round, contest: Optional[db 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') - if contest is not None: - return redirect(url_for('org_contest', id=contest.contest_id)) - else: - return redirect(url_for('org_round', id=round.round_id)) + return redirect(ctx.url_home()) else: errs = imp.errors warnings = imp.warnings @@ -387,6 +467,7 @@ def generic_import(round: db.Round, master_round: db.Round, contest: Optional[db return render_template( 'org_generic_import.html', + ctx=ctx, contest=contest, round=round, form=form, @@ -395,28 +476,21 @@ def generic_import(round: db.Round, master_round: db.Round, contest: Optional[db ) -@app.route('/doc/import') -def doc_import(): - return render_template('doc_import.html') - - -@app.route('/org/contest/c/<int:id>/import', methods=('GET', 'POST')) -def org_contest_import(id: int): - contest, master_contest, rr = get_contest_rr(id, Right.manage_contest) - return generic_import( - round=contest.round, master_round=master_contest.round, - contest=contest, master_contest=master_contest - ) - - # URL je explicitně uvedeno v mo.email.contestant_list_url -@app.route('/org/contest/c/<int:id>/participants', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:id>/site/<int:site_id>/participants', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:id>/participants/emails', endpoint="org_contest_list_emails") -@app.route('/org/contest/c/<int:id>/site/<int:site_id>/participants/emails', endpoint="org_contest_list_emails") -def org_contest_list(id: int, site_id: Optional[int] = None): - contest, master_contest, site, rr = get_contest_site_rr(id, site_id, Right.view_contestants) - can_edit = rr.have_right(Right.manage_contest) and request.endpoint != 'org_contest_list_emails' +@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')) +@app.route('/org/contest/c/<int:ct_id>/participants/emails', endpoint="org_generic_list_emails") +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/participants/emails', endpoint="org_generic_list_emails") +@app.route('/org/contest/r/<int:round_id>/participants', methods=('GET', 'POST')) +@app.route('/org/contest/r/<int:round_id>/participants/emails', endpoint="org_generic_list_emails") +@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/participants', methods=('GET', 'POST')) +@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/participants/emails', endpoint="org_generic_list_emails") +def org_generic_list(round_id: Optional[int] = None, hier_id: Optional[int] = None, + ct_id: Optional[int] = None, site_id: Optional[int] = None): + ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, site_id=site_id, right_needed=Right.view_contestants) + round, contest = ctx.master_round, ctx.master_contest + rr = ctx.rights + can_edit = rr.have_right(Right.manage_contest) and request.endpoint != 'org_generic_list_emails' format = request.args.get('format', "") filter = ParticipantsFilterForm(formdata=request.args) @@ -424,7 +498,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None): filter.validate() query = get_contestants_query( - round=master_contest.round, contest=master_contest, site=site, + ctx=ctx, school=filter.school.place, contest_place=filter.contest_place.place, participation_place=filter.participation_place.place, @@ -434,7 +508,7 @@ def org_contest_list(id: int, site_id: Optional[int] = None): action_form = None if can_edit: action_form = ParticipantsActionForm() - if action_form.do_action(round=contest.round, query=query): + if action_form.do_action(round=round, query=query): # Action happened, redirect return redirect(request.url) @@ -442,23 +516,26 @@ def org_contest_list(id: int, site_id: Optional[int] = None): table = None emails = None mailto_link = None - if request.endpoint == 'org_contest_list_emails': - (emails, mailto_link) = get_contestant_emails(query, - mailto_subject=f'{contest.round.name} {contest.round.category} {contest.place.name_locative()}') + if request.endpoint == 'org_generic_list_emails': + if contest: + subj = f'{contest.round.name} {contest.round.category} {contest.place.name_locative()}' + else: + subj = f'{round.name} kategorie {round.category}' + (emails, mailto_link) = get_contestant_emails(query, mailto_subject=subj) count = len(emails) else: - # (count, query) = filter.apply_limits(query, pagesize=50) - count = db.get_count(query) - table = make_contestant_table(query, master_contest.round, add_checkbox=can_edit) + (count, query) = filter.apply_limits(query, pagesize=50) + table = make_contestant_table(query, round, add_contest_column=(contest is None), add_checkbox=can_edit) return render_template( - 'org_contest_list.html', - contest=contest, site=site, + 'org_generic_list.html', + ctx=ctx, + contest=contest, round=round, site=ctx.site, table=table, emails=emails, mailto_link=mailto_link, filter=filter, count=count, action_form=action_form, ) else: - table = make_contestant_table(query, master_contest.round, is_export=True) + table = make_contestant_table(query, round, is_export=True) return table.send_as(format) @@ -476,8 +553,7 @@ contest_list_columns = ( def get_contestants_query( - round: db.Round, contest: Optional[db.Contest] = None, - site: Optional[db.Place] = None, + ctx: Context, contest_place: Optional[db.Place] = None, participation_place: Optional[db.Place] = None, participation_state: Optional[db.PartState] = None, @@ -487,15 +563,18 @@ def get_contestants_query( .select_from(db.Participation) .join(db.Participant, db.Participant.user_id == db.Participation.user_id) .join(db.User, db.User.user_id == db.Participation.user_id) - .filter(db.Participant.year == round.year)) - if contest: - query = query.join(db.Contest, db.Contest.contest_id == contest.contest_id) + .join(db.Contest) + .filter(db.Participant.year == ctx.round.year) + .filter(db.Participation.contest_id == db.Contest.contest_id)) + if ctx.contest: + query = query.filter(db.Contest.contest_id == ctx.contest.contest_id) + if ctx.site: + query = query.filter(db.Participation.place_id == ctx.site.place_id) else: - query = query.filter(db.Contest.round == round) + query = query.filter(db.Contest.round == ctx.round) + if ctx.hier_place: + query = db.filter_place_nth_parent(query, db.Contest.place_id, ctx.round.level - ctx.hier_place.level, ctx.hier_place.place_id) query = query.options(joinedload(db.Contest.place)) - query = query.filter(db.Participation.contest_id == db.Contest.contest_id) - if site: - query = query.filter(db.Participation.place_id == site.place_id) if contest_place: query = query.filter(db.Contest.place_id == contest_place.place_id) if participation_place: @@ -570,91 +649,6 @@ def get_contestant_emails(query: Query, mailto_subject: str = '[OSMO] Zpráva pr return (emails, mailto_link) -@dataclass -class SolutionContext: - contest: db.Contest - master_contest: db.Contest - round: db.Round - master_round: db.Round - pion: Optional[db.Participation] - user: Optional[db.User] - task: Optional[db.Task] - site: Optional[db.Place] - allow_view: bool - allow_upload_solutions: bool - allow_upload_feedback: bool - allow_create_solutions: bool - allow_edit_points: bool - - -def get_solution_context(contest_id: int, user_id: Optional[int], task_id: Optional[int], site_id: Optional[int]) -> SolutionContext: - sess = db.get_session() - - # Nejprve zjistíme, zda existuje soutěž - contest, master_contest = get_contest(contest_id) - round = contest.round - master_round = master_contest.round - - # Najdeme úlohu a ověříme, že je součástí soutěže - if task_id is not None: - task = sess.query(db.Task).get(task_id) - if not task or task.round != round: - raise werkzeug.exceptions.NotFound() - else: - task = None - - site = None - user = None - if user_id is not None: - # Zkontrolujeme, zda se účastník opravdu účastní soutěže - pion = (sess.query(db.Participation) - .filter_by(user_id=user_id, contest_id=master_contest.contest_id) - .options(joinedload(db.Participation.place), - joinedload(db.Participation.user)) - .one_or_none()) - if not pion: - raise werkzeug.exceptions.NotFound() - user = pion.user - - # A zda soutěží na zadaném soutěžním místě, je-li určeno - if site_id is not None and site_id != pion.place_id: - raise werkzeug.exceptions.NotFound() - - # Pokud je uvedeno soutěžní místo, hledáme práva k němu, jinak k soutěži - if site_id is not None: - site = pion.place - else: - pion = None - - if site_id is not None: - site = sess.query(db.Place).get(site_id) - if not site: - raise werkzeug.exceptions.NotFound() - - rr = g.gatekeeper.rights_for_contest(contest, site) - - allow_view = rr.have_right(Right.view_submits) - if not allow_view: - raise werkzeug.exceptions.Forbidden() - - allow_upload_solutions = rr.can_upload_solutions() - allow_upload_feedback = rr.can_upload_feedback() - return SolutionContext( - contest=contest, master_contest=master_contest, - round=round, master_round=master_round, - pion=pion, - user=user, - task=task, - site=site, - # XXX: Potřebujeme tohle všechno? Nechceme spíš vracet rr a nechat každého, ať na něm volá metody? - allow_view=allow_view, - allow_upload_solutions=allow_upload_solutions, - allow_upload_feedback=allow_upload_feedback, - allow_create_solutions=allow_upload_solutions or allow_upload_feedback, - allow_edit_points=rr.can_edit_points(), - ) - - class SubmitForm(FlaskForm): note = wtforms.TextAreaField("Poznámka pro účastníka", description="Viditelná účastníkovi po uzavření kola", render_kw={'autofocus': True}) org_note = wtforms.TextAreaField("Interní poznámka", description="Viditelná jen organizátorům") @@ -674,15 +668,15 @@ class SetFinalForm(FlaskForm): submit_final = wtforms.SubmitField("Prohlásit za finální") -@app.route('/org/contest/c/<int:contest_id>/submit/<int:user_id>/<int:task_id>/', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/submit/<int:user_id>/<int:task_id>/', methods=('GET', 'POST')) -def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Optional[int] = None): - sc = get_solution_context(contest_id, user_id, task_id, site_id) - assert sc.user is not None - assert sc.task is not None +@app.route('/org/contest/c/<int:ct_id>/submit/<int:user_id>/<int:task_id>/', methods=('GET', 'POST')) +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/submit/<int:user_id>/<int:task_id>/', methods=('GET', 'POST')) +def org_submit_list(ct_id: int, user_id: int, task_id: int, site_id: Optional[int] = None): + ctx = get_context(ct_id=ct_id, site_id=site_id, user_id=user_id, task_id=task_id, right_needed=Right.view_submits) + assert ctx.contest and ctx.user and ctx.task + rights = ctx.rights sess = db.get_session() - self_url = url_for('org_submit_list', contest_id=contest_id, user_id=user_id, task_id=task_id, site_id=site_id) + self_url = ctx.url_for('org_submit_list') # Najdeme řešení úlohy (nemusí existovat) sol = (sess.query(db.Solution) @@ -690,7 +684,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option .one_or_none()) set_final_form: Optional[SetFinalForm] = None - if sol and sc.allow_upload_feedback: + if sol and rights.can_upload_feedback(): set_final_form = SetFinalForm() if set_final_form.validate_on_submit() and set_final_form.submit_final.data: is_submit = set_final_form.type.data == "submit" @@ -737,7 +731,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option return redirect(self_url) form = SubmitForm(obj=sol) - form.points.widget = NumberInput(min=0, max=sc.task.max_points, step=sc.master_round.points_step) # min a max v HTML + form.points.widget = NumberInput(min=0, max=ctx.task.max_points, step=ctx.master_round.points_step) # min a max v HTML if form.validate_on_submit(): if sol and form.delete.data: if sol.final_submit or sol.final_feedback: @@ -747,14 +741,14 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option sess.delete(sol) mo.util.log( type=db.LogType.participant, - what=sc.user.user_id, + what=ctx.user.user_id, details={ 'action': 'solution-removed', 'task': task_id, }, ) sess.commit() - app.logger.info(f"Řešení úlohy {sc.task.code} od účastníka {sc.user.user_id} smazáno") + app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {ctx.user.user_id} smazáno") return redirect(self_url) points = form.points.data @@ -763,28 +757,28 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option flash('Schází soubor k nahrání, žádné změny nebyly uloženy', 'danger') return redirect(self_url) if points: - error = mo.util.check_points(points, for_task=sc.task, for_round=sc.round) + error = mo.util.check_points(points, for_task=ctx.task, for_round=ctx.round) if error: flash(error, 'danger') return redirect(self_url) - if not sol and (sc.allow_edit_points or sc.allow_upload_solutions or sc.allow_upload_feedback): + if not sol and (rights.can_edit_points() or rights.can_upload_solutions() or rights.can_upload_feedback()): flash('Řešení založeno', 'success') - sol = db.Solution(task=sc.task, user=sc.user) + sol = db.Solution(task=ctx.task, user=ctx.user) sess.add(sol) mo.util.log( type=db.LogType.participant, - what=sc.user.user_id, + what=ctx.user.user_id, details={ 'action': 'solution-created', 'task': task_id, }, ) sess.commit() - app.logger.info(f"Řešení úlohy {sc.task.code} od účastníka {sc.user.user_id} založeno") + app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {ctx.user.user_id} založeno") # Edit sol and points - if sol and sc.allow_edit_points: + if sol and rights.can_edit_points(): # Sol edit sol.note = form.note.data sol.org_note = form.org_note.data @@ -794,7 +788,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option if points != sol.points: sol.points = points sess.add(db.PointsHistory( - task=sc.task, + task=ctx.task, participant=sol.user, user=g.user, points_at=mo.now, @@ -806,7 +800,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option changes = db.get_object_changes(sol) mo.util.log( type=db.LogType.participant, - what=sc.user.user_id, + what=ctx.user.user_id, details={ 'action': 'solution-edit', 'task': task_id, @@ -814,22 +808,22 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option }, ) sess.commit() - app.logger.info(f"Řešení úlohy {sc.task.code} od účastníka {sc.user.user_id} modifikováno, změny: {changes}") + app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {ctx.user.user_id} modifikováno, změny: {changes}") - if (form.submit_sol.data and sc.allow_upload_solutions) or (form.submit_fb.data and sc.allow_upload_feedback): + if (form.submit_sol.data and rights.can_upload_solutions()) or (form.submit_fb.data and rights.can_upload_feedback()): file = form.file.data.stream - if sc.allow_upload_solutions and form.submit_sol.data: + if rights.can_upload_solutions() and form.submit_sol.data: type = db.PaperType.solution - elif sc.allow_upload_feedback and form.submit_fb.data: + elif rights.can_upload_feedback() and form.submit_fb.data: type = db.PaperType.feedback else: raise werkzeug.exceptions.Forbidden() - assert sc.task is not None and sc.user is not None - paper = db.Paper(task=sc.task, for_user_obj=sc.user, uploaded_by_obj=g.user, type=type, note=form.file_note.data) + assert ctx.task is not None and ctx.user is not None + paper = db.Paper(task=ctx.task, for_user_obj=ctx.user, uploaded_by_obj=g.user, type=type, note=form.file_note.data) submitter = mo.submit.Submitter() - self_url = url_for('org_submit_list', contest_id=contest_id, user_id=user_id, task_id=task_id, site_id=site_id) + self_url = url_for('org_submit_list', ct_id=ct_id, user_id=user_id, task_id=task_id, site_id=site_id) try: submitter.submit_paper(paper, file.name) @@ -859,7 +853,7 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option return redirect(self_url) papers = (sess.query(db.Paper) - .filter_by(for_user_obj=sc.user, task=sc.task) + .filter_by(for_user_obj=ctx.user, task=ctx.task) .options(joinedload(db.Paper.uploaded_by_obj)) .order_by(db.Paper.uploaded_at.desc()) .all()) @@ -868,21 +862,21 @@ def org_submit_list(contest_id: int, user_id: int, task_id: int, site_id: Option fb_papers = [p for p in papers if p.type == db.PaperType.feedback] points_history = (sess.query(db.PointsHistory) - .filter_by(task=sc.task, participant=sc.user) + .filter_by(task=ctx.task, participant=ctx.user) .options(joinedload(db.PointsHistory.user)) .order_by(db.PointsHistory.points_at.desc()) .all()) return render_template( 'org_submit_list.html', - sc=sc, + ctx=ctx, rights=rights, solution=sol, sol_papers=sol_papers, fb_papers=fb_papers, points_history=points_history, for_site=(site_id is not None), - paper_link=lambda p: mo.web.util.org_paper_link(sc.contest, sc.site, sc.user, p), - orig_paper_link=lambda p: mo.web.util.org_paper_link(sc.contest, sc.site, sc.user, p, orig=True), + paper_link=lambda p: mo.web.util.org_paper_link(ctx.contest, ctx.site, ctx.user, p), + orig_paper_link=lambda p: mo.web.util.org_paper_link(ctx.contest, ctx.site, ctx.user, p, orig=True), form=form, set_final_form=set_final_form, ) @@ -894,11 +888,11 @@ class SubmitEditForm(FlaskForm): submit = wtforms.SubmitField("Uložit") -@app.route('/org/contest/c/<int:contest_id>/paper/<int:paper_id>/<filename>', endpoint='org_submit_paper') -@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/paper/<int:paper_id>/<filename>', endpoint='org_submit_paper') -@app.route('/org/contest/c/<int:contest_id>/paper/orig/<int:paper_id>/<filename>', endpoint='org_submit_paper_orig') -@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/paper/orig/<int:paper_id>/<filename>', endpoint='org_submit_paper_orig') -def org_submit_paper(contest_id: int, paper_id: int, filename: str, site_id: Optional[int] = None): +@app.route('/org/contest/c/<int:ct_id>/paper/<int:paper_id>/<filename>', endpoint='org_submit_paper') +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/paper/<int:paper_id>/<filename>', endpoint='org_submit_paper') +@app.route('/org/contest/c/<int:ct_id>/paper/orig/<int:paper_id>/<filename>', endpoint='org_submit_paper_orig') +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/paper/orig/<int:paper_id>/<filename>', endpoint='org_submit_paper_orig') +def org_submit_paper(ct_id: int, paper_id: int, filename: str, site_id: Optional[int] = None): paper = (db.get_session().query(db.Paper) .options(joinedload(db.Paper.task)) # pro task_paper_filename() .get(paper_id)) @@ -908,7 +902,7 @@ def org_submit_paper(contest_id: int, paper_id: int, filename: str, site_id: Opt if not filename.endswith('.pdf'): raise werkzeug.exceptions.NotFound() - get_solution_context(contest_id, paper.for_user, paper.for_task, site_id) + get_context(ct_id=ct_id, user_id=paper.for_user, task_id=paper.for_task, site_id=site_id, right_needed=Right.view_submits) return mo.web.util.send_task_paper(paper, (request.endpoint == 'org_submit_paper_orig')) @@ -941,25 +935,25 @@ class TaskCreateForm(FlaskForm): submit = wtforms.SubmitField("Založit označená řešení") -@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/') -@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/') -@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/points', methods=('GET', 'POST'), endpoint="org_contest_task_points") -@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create") -@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create") -def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = None): - sc = get_solution_context(contest_id, None, task_id, site_id) - assert sc.task is not None +@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/') +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/task/<int:task_id>/') +@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/points', methods=('GET', 'POST'), endpoint="org_contest_task_points") +@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create") +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/task/<int:task_id>/create', methods=('GET', 'POST'), endpoint="org_contest_task_create") +def org_contest_task(ct_id: int, task_id: int, site_id: Optional[int] = None): + ctx = get_context(ct_id=ct_id, site_id=site_id, task_id=task_id, right_needed=Right.view_submits) + assert ctx.contest and ctx.task action_create = request.endpoint == "org_contest_task_create" action_points = request.endpoint == "org_contest_task_points" - if action_create and not sc.allow_create_solutions: + if action_create and not ctx.rights.can_create_solutions(): raise werkzeug.exceptions.Forbidden() - if action_points and not sc.allow_edit_points: + if action_points and not ctx.rights.can_edit_points(): raise werkzeug.exceptions.Forbidden() sess = db.get_session() - q = get_solutions_query(sc.task, for_contest=sc.master_contest, for_site=sc.site) + q = get_solutions_query(ctx.task, for_contest=ctx.master_contest, for_site=ctx.site) rows: List[Tuple[db.Participation, db.Solution]] = q.all() rows.sort(key=lambda r: r[0].user.sort_key()) @@ -976,7 +970,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non if not request.form.get(f"create_sol_{pion.user_id}"): continue # nikdo nežádá o vytvoření - sol = db.Solution(task=sc.task, user=pion.user) + sol = db.Solution(task=ctx.task, user=pion.user) sess.add(sol) mo.util.log( type=db.LogType.participant, @@ -986,7 +980,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non 'task': task_id, }, ) - app.logger.info(f"Řešení úlohy {sc.task.code} od účastníka {pion.user_id} založeno") + app.logger.info(f"Řešení úlohy {ctx.task.code} od účastníka {pion.user_id} založeno") new_sol_count += 1 if new_sol_count > 0: @@ -996,7 +990,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non "success") else: flash("Žádné změny k uložení", "info") - return redirect(url_for('org_contest_task', contest_id=contest_id, task_id=task_id, site_id=site_id)) + return redirect(ctx.url_for('org_contest_task')) if action_points: points_form = TaskPointsForm() @@ -1007,7 +1001,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non if sol is None: continue - points, error = mo.util.parse_points(request.form.get(f"points_{sol.user_id}"), for_task=sc.task, for_round=sc.round) + points, error = mo.util.parse_points(request.form.get(f"points_{sol.user_id}"), for_task=ctx.task, for_round=ctx.round) if error: flash(f'{sol.user.first_name} {sol.user.last_name}: {error}', 'danger') ok = False @@ -1016,7 +1010,7 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non # Save points sol.points = points sess.add(db.PointsHistory( - task=sc.task, + task=ctx.task, participant=sol.user, user=g.user, points_at=mo.now, @@ -1029,13 +1023,13 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non flash("Změněny body u " + inflect_number(count, "řešení", "řešení", "řešení"), "success") else: flash("Žádné změny k uložení", "info") - return redirect(url_for('org_contest_task', contest_id=contest_id, task_id=task_id)) + return redirect(ctx.url_for('org_contest_task')) # Count papers for each solution paper_counts = {} for user_id, type, count in ( db.get_session().query(db.Paper.for_user, db.Paper.type, func.count(db.Paper.type)) - .filter_by(task=sc.task) + .filter_by(task=ctx.task) .group_by(db.Paper.for_user, db.Paper.type) .all() ): @@ -1043,8 +1037,10 @@ def org_contest_task(contest_id: int, task_id: int, site_id: Optional[int] = Non return render_template( "org_contest_task.html", - sc=sc, rows=rows, paper_counts=paper_counts, - paper_link=lambda u, p: mo.web.util.org_paper_link(sc.contest, sc.site, u, p), + ctx=ctx, rights=ctx.rights, + round=ctx.round, contest=ctx.contest, + rows=rows, paper_counts=paper_counts, + paper_link=lambda u, p: mo.web.util.org_paper_link(ctx.contest, ctx.site, u, p), points_form=points_form, create_form=create_form, request_form=request.form, ) @@ -1053,33 +1049,34 @@ class ContestSolutionsEditForm(FlaskForm): submit = wtforms.SubmitField("Založit označená řešení") -@app.route('/org/contest/c/<int:id>/solutions', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:id>/site/<int:site_id>/solutions', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:id>/solutions/edit', methods=('GET', 'POST'), endpoint="org_contest_solutions_edit") -@app.route('/org/contest/c/<int:id>/site/<int:site_id>/solutions/edit', methods=('GET', 'POST'), endpoint="org_contest_solutions_edit") -def org_contest_solutions(id: int, site_id: Optional[int] = None): - sc = get_solution_context(id, None, None, site_id) +@app.route('/org/contest/c/<int:ct_id>/solutions', methods=('GET', 'POST')) +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/solutions', methods=('GET', 'POST')) +@app.route('/org/contest/c/<int:ct_id>/solutions/edit', methods=('GET', 'POST'), endpoint="org_contest_solutions_edit") +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/solutions/edit', methods=('GET', 'POST'), endpoint="org_contest_solutions_edit") +def org_contest_solutions(ct_id: int, site_id: Optional[int] = None): sess = db.get_session() + ctx = get_context(ct_id=ct_id, site_id=site_id, right_needed=Right.view_submits) + assert ctx.contest edit_action = request.endpoint == "org_contest_solutions_edit" - if edit_action and not sc.allow_create_solutions: + if edit_action and not ctx.rights.can_create_solutions(): raise werkzeug.exceptions.Forbidden() - pions_subq = sess.query(db.Participation.user_id).filter_by(contest=sc.master_contest) - if sc.site: - pions_subq = pions_subq.filter_by(place=sc.site) + pions_subq = sess.query(db.Participation.user_id).filter_by(contest=ctx.master_contest) + if ctx.site: + pions_subq = pions_subq.filter_by(place=ctx.site) pions_subq = pions_subq.subquery() pions = (sess.query(db.Participation) .filter( - db.Participation.contest == sc.master_contest, + db.Participation.contest == ctx.master_contest, db.Participation.user_id.in_(pions_subq), ).options(joinedload(db.Participation.user)) .all()) pions.sort(key=lambda p: p.user.sort_key()) - tasks_subq = sess.query(db.Task.task_id).filter_by(round=sc.round).subquery() + tasks_subq = sess.query(db.Task.task_id).filter_by(round=ctx.round).subquery() tasks = (sess.query(db.Task) - .filter_by(round=sc.round) + .filter_by(round=ctx.round) .order_by(db.Task.code) .all()) @@ -1139,13 +1136,14 @@ def org_contest_solutions(id: int, site_id: Optional[int] = None): "success") else: flash("Žádné změny k uložení", "info") - return redirect(url_for('org_contest_solutions', id=id, site_id=site_id)) + return redirect(ctx.url_for('org_contest_solutions')) return render_template( 'org_contest_solutions.html', - contest=sc.contest, site=sc.site, sc=sc, + ctx=ctx, + contest=ctx.contest, site=ctx.site, rights=ctx.rights, pions=pions, tasks=tasks, tasks_sols=task_sols, paper_counts=paper_counts, - paper_link=lambda u, p: mo.web.util.org_paper_link(sc.contest, sc.site, u, p), + paper_link=lambda u, p: mo.web.util.org_paper_link(ctx.contest, ctx.site, u, p), edit_form=edit_form, ) @@ -1200,23 +1198,30 @@ def download_submits(form: DownloadSubmitsForm, round: db.Round, sol_query, pion return True -def generic_batch_download(round: db.Round, contest: Optional[db.Contest], site: Optional[db.Place], task: db.Task): - """Společná funkce pro download submitů/feedbacku do soutěží a kol.""" - +@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/download', methods=('GET', 'POST')) +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/task/<int:task_id>/download', methods=('GET', 'POST')) +@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/download', methods=('GET', 'POST')) +@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/task/<int:task_id>/download', methods=('GET', 'POST')) +def org_generic_batch_download(task_id: int, round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None, site_id: Optional[int] = None): sess = db.get_session() + ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, site_id=site_id, task_id=task_id, right_needed=Right.view_submits) + round, hier_place, contest, site, task = ctx.round, ctx.hier_place, ctx.contest, ctx.site, ctx.task + assert task pion_query = sess.query(db.Participation.user_id).select_from(db.Participation) - if contest is not None: + if contest: pion_query = pion_query.filter_by(contest_id=contest.master_contest_id) - if site is not None: + if site: pion_query = pion_query.filter_by(place=site) else: pion_query = pion_query.join(db.Contest).filter(db.Contest.round_id == round.master_round_id) + if hier_place: + pion_query = db.filter_place_nth_parent(pion_query, db.Contest.place_id, round.level - hier_place.level, hier_place.place_id) sol_query = (sess.query(db.Solution) .select_from(db.Solution) .filter(db.Solution.task == task)) - if contest is not None: + if contest or hier_place: sol_query = sol_query.filter(db.Solution.user_id.in_(pion_query.subquery())) form = DownloadSubmitsForm() @@ -1226,6 +1231,8 @@ def generic_batch_download(round: db.Round, contest: Optional[db.Contest], site: subj = f'{subj} ({site.name})' elif contest is not None: subj = f'{subj} ({contest.place.name})' + elif hier_place is not None: + subj = f'{subj} ({hier_place.name})' if download_submits(form, round, sol_query, pion_query, subj, contest is None): return redirect(url_for('org_jobs')) @@ -1244,7 +1251,8 @@ def generic_batch_download(round: db.Round, contest: Optional[db.Contest], site: return render_template( 'org_generic_batch_download.html', - round=round, contest=contest, site=site, task=task, + ctx=ctx, + task=task, submit_count=submit_count, pion_count=pion_count, sol_count=sol_count, fb_count=fb_count, @@ -1258,13 +1266,18 @@ class UploadSubmitsForm(FlaskForm): submit = wtforms.SubmitField('Odeslat') -def generic_batch_upload(round: db.Round, contest: Optional[db.Contest], site: Optional[db.Place], task: db.Task, - offer_upload_solutions: bool, offer_upload_feedback: bool): - """Společná funkce pro upload feedbacku do soutěží a kol.""" +@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/upload', methods=('GET', 'POST')) +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/task/<int:task_id>/upload', methods=('GET', 'POST')) +@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/upload', methods=('GET', 'POST')) +@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/task/<int:task_id>/upload', methods=('GET', 'POST')) +def org_generic_batch_upload(task_id: int, round_id: Optional[int] = None, hier_id: Optional[int] = None, ct_id: Optional[int] = None, site_id: Optional[int] = None): + ctx = get_context(round_id=round_id, hier_id=hier_id, ct_id=ct_id, site_id=site_id, task_id=task_id) + round, hier_place, contest, site, task = ctx.round, ctx.hier_place, ctx.contest, ctx.site, ctx.task + assert task - # Základní kontrola, zda vůbec chceme akci spustit. + # Základní kontrola, zda vůbec chceme akci spustit. Zbytek se kontrole uvnitř jobu. # Zatím neumíme dávkově nahrávat řešení. - if not offer_upload_feedback: + if not ctx.rights.offer_upload_feedback(): raise werkzeug.exceptions.Forbidden() request.custom_max_content_length = mo.config.MAX_BATCH_CONTENT_LENGTH @@ -1274,35 +1287,18 @@ def generic_batch_upload(round: db.Round, contest: Optional[db.Contest], site: O file = form.file.data.stream mo.jobs.submit.schedule_upload_feedback(round, file.name, f'Nahrání opravených řešení {round.round_code()}', for_user=g.user, - only_contest=contest, only_site=site, only_task=task) + only_contest=contest, only_site=site, only_region=hier_place, only_task=task) return redirect(url_for('org_jobs')) return render_template( 'org_generic_batch_upload.html', - round=round, contest=contest, site=site, task=task, + ctx=ctx, + task=task, max_size=mo.config.MAX_BATCH_CONTENT_LENGTH, form=form, ) -@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/download', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/download', methods=('GET', 'POST')) -def org_contest_task_download(contest_id: int, task_id: int, site_id: Optional[int] = None): - sc = get_solution_context(contest_id, None, task_id, site_id) - assert sc.task is not None - return generic_batch_download(round=sc.round, contest=sc.contest, site=sc.site, task=sc.task) - - -@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/upload', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:contest_id>/site/<int:site_id>/task/<int:task_id>/upload', methods=('GET', 'POST')) -def org_contest_task_upload(contest_id: int, task_id: int, site_id: Optional[int] = None): - sc = get_solution_context(contest_id, None, task_id, site_id) - assert sc.task is not None - return generic_batch_upload(round=sc.round, contest=sc.contest, site=sc.site, task=sc.task, - offer_upload_solutions=sc.allow_upload_solutions, - offer_upload_feedback=sc.allow_upload_feedback) - - class BatchPointsForm(FlaskForm): file = flask_wtf.file.FileField("Soubor", render_kw={'autofocus': True}) fmt = wtforms.SelectField( @@ -1315,15 +1311,22 @@ class BatchPointsForm(FlaskForm): get_template = wtforms.SubmitField('Stáhnout šablonu') -def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: db.Task): - """Společná funkce pro download/upload bodů do soutěží a kol.""" +@app.route('/org/contest/c/<int:ct_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST')) +@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST')) +@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST')) +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 + + if not ctx.rights.can_edit_points(): + raise werkzeug.exceptions.Forbidden() form = BatchPointsForm() errs = [] warnings = [] if form.validate_on_submit(): fmt = form.fmt.data - imp = create_import(user=g.user, type=ImportType.points, fmt=fmt, round=round, contest=contest, task=task, allow_add_del=form.add_del_sols.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 @@ -1334,10 +1337,7 @@ def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: d 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') - if contest is not None: - return redirect(url_for('org_contest', id=contest.contest_id)) - else: - return redirect(url_for('org_round', id=round.round_id)) + return redirect(ctx.url_home()) else: errs = imp.errors warnings = imp.warnings @@ -1352,6 +1352,7 @@ def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: d return render_template( 'org_generic_batch_points.html', + ctx=ctx, round=round, contest=contest, task=task, form=form, errs=errs, @@ -1359,37 +1360,27 @@ def generic_batch_points(round: db.Round, contest: Optional[db.Contest], task: d ) -@app.route('/org/contest/c/<int:contest_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST')) -def org_contest_task_batch_points(contest_id: int, task_id: int): - sc = get_solution_context(contest_id, None, task_id, None) - assert sc.task is not None - - if not sc.allow_edit_points: - raise werkzeug.exceptions.Forbidden() - - return generic_batch_points(round=sc.round, contest=sc.contest, task=sc.task) - - -@app.route('/org/contest/c/<int:contest_id>/user/<int:user_id>') -def org_contest_user(contest_id: int, user_id: int): - sc = get_solution_context(contest_id, user_id, None, None) +@app.route('/org/contest/c/<int:ct_id>/user/<int:user_id>') +def org_contest_user(ct_id: int, user_id: int): + ctx = get_context(ct_id=ct_id, user_id=user_id, right_needed=Right.view_contestants) + assert ctx.contest sess = db.get_session() pant = sess.query(db.Participant).filter_by( - user_id=user_id, year=sc.round.year + user_id=user_id, year=ctx.round.year ).options(joinedload(db.Participant.school_place)).one_or_none() if not pant: raise werkzeug.exceptions.NotFound() task_sols = sess.query(db.Task, db.Solution).select_from(db.Task).outerjoin( - db.Solution, and_(db.Solution.task_id == db.Task.task_id, db.Solution.user == sc.user) - ).filter(db.Task.round == sc.round).options( + db.Solution, and_(db.Solution.task_id == db.Task.task_id, db.Solution.user == ctx.user) + ).filter(db.Task.round == ctx.round).options( joinedload(db.Solution.final_submit_obj), joinedload(db.Solution.final_feedback_obj) ).order_by(db.Task.code).all() # Count papers for each task and solution - tasks_subq = sess.query(db.Task.task_id).filter_by(round=sc.round).subquery() + tasks_subq = sess.query(db.Task.task_id).filter_by(round=ctx.round).subquery() paper_counts = {} for task_id, type, count in ( db.get_session().query(db.Paper.for_task, db.Paper.type, func.count(db.Paper.type)) @@ -1403,8 +1394,9 @@ def org_contest_user(contest_id: int, user_id: int): return render_template( 'org_contest_user.html', - sc=sc, pant=pant, task_sols=task_sols, - paper_link=lambda u, p: mo.web.util.org_paper_link(sc.contest, None, u, p), + ctx=ctx, rights=ctx.rights, + pant=pant, task_sols=task_sols, + paper_link=lambda u, p: mo.web.util.org_paper_link(ctx.contest, None, u, p), paper_counts=paper_counts, ) @@ -1420,16 +1412,18 @@ class AdvanceForm(FlaskForm): execute = wtforms.SubmitField('Provést') -@app.route('/org/contest/c/<int:contest_id>/advance', methods=('GET', 'POST')) -def org_contest_advance(contest_id: int): +@app.route('/org/contest/c/<int:ct_id>/advance', methods=('GET', 'POST')) +def org_contest_advance(ct_id: int): sess = db.get_session() conn = sess.connection() - contest, master_contest, rr = get_contest_rr(contest_id, Right.manage_contest) + ctx = get_context(ct_id=ct_id, right_needed=Right.manage_contest) + contest, master_contest = ctx.contest, ctx.master_contest + round = ctx.round + assert contest and master_contest def redirect_back(): - return redirect(url_for('org_contest', id=contest_id)) + return redirect(ctx.url_for('org_contest')) - round = contest.round if round.state != db.RoundState.preparing: flash('Aktuální kolo není ve stavu přípravy', 'danger') return redirect_back() @@ -1519,7 +1513,7 @@ def org_contest_advance(contest_id: int): if inserted: # Opravdu došlo ke vložení really_inserted += 1 - app.logger.info(f'Postup: Založena účast user=#{pp.user_id} contest=#{contest_id} place=#{contest.place_id}') + app.logger.info(f'Postup: Založena účast user=#{pp.user_id} contest=#{ct_id} place=#{contest.place_id}') mo.util.log( type=db.LogType.participant, what=pp.user_id, @@ -1544,6 +1538,7 @@ def org_contest_advance(contest_id: int): return render_template( 'org_contest_advance.html', + ctx=ctx, contest=contest, round=contest.round, prev_round=prev_round, @@ -1561,11 +1556,13 @@ class ContestEditForm(FlaskForm): submit = wtforms.SubmitField('Uložit') -@app.route('/org/contest/c/<int:id>/edit', methods=('GET', 'POST')) -def org_contest_edit(id: int): +@app.route('/org/contest/c/<int:ct_id>/edit', methods=('GET', 'POST')) +def org_contest_edit(ct_id: int): sess = db.get_session() - contest, _, rr = get_contest_rr(id, Right.manage_contest) - round = contest.round + ctx = get_context(ct_id=ct_id, right_needed=Right.manage_contest) + contest = ctx.contest + round = ctx.round + assert contest and round form = ContestEditForm(obj=contest) if round.state != db.RoundState.delegate: @@ -1580,31 +1577,31 @@ def org_contest_edit(id: int): if 'state' in changes and round.state != db.RoundState.delegate: flash("Nastavení kola neumožňuje měnit stav soutěže", "danger") - return redirect(url_for('org_contest', id=id)) + return redirect(url_for('org_contest', ct_id=ct_id)) - app.logger.info(f"Contest #{id} modified, changes: {changes}") + app.logger.info(f"Contest #{ct_id} modified, changes: {changes}") mo.util.log( type=db.LogType.contest, - what=id, + what=ct_id, details={'action': 'edit', 'changes': changes}, ) sess.commit() flash('Změny soutěže uloženy', 'success') else: - flash(u'Žádné změny k uložení', 'info') + flash('Žádné změny k uložení', 'info') - return redirect(url_for('org_contest', id=id)) + return redirect(ctx.url_for('org_contest')) return render_template( 'org_contest_edit.html', + ctx=ctx, round=round, contest=contest, form=form, ) - class ParticipantAddForm(FlaskForm): email = mo_fields.Email(validators=[validators.Required()]) first_name = mo_fields.FirstName(validators=[validators.Optional()]) @@ -1620,17 +1617,19 @@ class ParticipantAddForm(FlaskForm): self.participation_place.description = f'Pokud účastník soutěží někde jinde než {contest.place.name_locative()}, vyplňte <a href="{url_for("org_place", id=contest.place.place_id)}">kód místa</a>. Dozor na tomto místě pak může za účastníka odevzdávat řešení.' -@app.route('/org/contest/c/<int:id>/participants/new', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:id>/site/<int:site_id>/participants/new', methods=('GET', 'POST')) -def org_contest_add_user(id: int, site_id: Optional[int] = None): - contest, master_contest, site, rr = get_contest_site_rr(id, site_id, right_needed=Right.manage_contest) +@app.route('/org/contest/c/<int:ct_id>/participants/new', methods=('GET', 'POST')) +@app.route('/org/contest/c/<int:ct_id>/site/<int:site_id>/participants/new', methods=('GET', 'POST')) +def org_contest_add_user(ct_id: int, site_id: Optional[int] = None): + ctx = get_context(ct_id=ct_id, site_id=site_id, right_needed=Right.manage_contest) + contest = ctx.master_contest + assert contest form = ParticipantAddForm() if site_id is not None: if not form.is_submitted(): form.participation_place.process_data(site_id) form.participation_place.render_kw = {"readonly": True} - form.set_descriptions(master_contest) + form.set_descriptions(contest) if form.validate_on_submit(): try: @@ -1652,10 +1651,11 @@ def org_contest_add_user(id: int, site_id: Optional[int] = None): flash("Uživatel přihlášen do soutěže.", "info") else: flash("Žádná změna. Uživatel už byl přihlášen.", "info") - return redirect(url_for('org_contest_list', id=id, site_id=site_id)) + return redirect(ctx.url_for('org_generic_list')) return render_template( 'org_contest_add_user.html', - contest=master_contest, site=site, + ctx=ctx, + contest=contest, round=ctx.master_round, site=ctx.site, form=form ) diff --git a/mo/web/org_place.py b/mo/web/org_place.py index 28fb9bab4f7adfb960e4c806a61b0feb8142a9ca..02f95161effa34162e3770ba663fc0b2afb29e87 100644 --- a/mo/web/org_place.py +++ b/mo/web/org_place.py @@ -81,8 +81,8 @@ class PlaceSchoolEditForm(PlaceEditForm): def place_breadcrumbs(place: db.Place, action: Optional[str] = None) -> Markup: elements = [] - parents: List[db.Place] = reversed(g.gatekeeper.get_parents(place)) - for parent in parents: + ancestors: List[db.Place] = g.gatekeeper.get_ancestors(place) + for parent in ancestors: elements.append((url_for('org_place', id=parent.place_id), parent.name)) if action: elements.append(('', action)) @@ -215,7 +215,7 @@ def org_place_move(id: int): return redirect(url_for('org_place_move', id=id)) new_parent = form.new_parent.place - new_parents = reversed(g.gatekeeper.get_parents(new_parent)) + new_parents = g.gatekeeper.get_ancestors(new_parent) (_, levels) = db.place_type_names_and_levels[place.type] rr = g.gatekeeper.rights_for(new_parent) @@ -370,7 +370,7 @@ def org_place_rights(id: int): if not place: raise werkzeug.exceptions.NotFound() - parent_ids = [p.place_id for p in g.gatekeeper.get_parents(place)] + parent_ids = [p.place_id for p in g.gatekeeper.get_ancestors(place)] roles = (sess.query(db.UserRole) .filter(db.UserRole.place_id.in_(parent_ids)) .options(joinedload(db.UserRole.user)) diff --git a/mo/web/org_round.py b/mo/web/org_round.py index 88da8505708ee535929ad8fb9ccc2cc639a9153a..5a12dbe9555119d6bb32d5e01613b8458a025295 100644 --- a/mo/web/org_round.py +++ b/mo/web/org_round.py @@ -1,5 +1,6 @@ +from dataclasses import dataclass, field import decimal -from flask import render_template, g, redirect, url_for, flash, request +from flask import render_template, g, redirect, flash, request import locale import flask_wtf.file from flask_wtf.form import FlaskForm @@ -8,53 +9,29 @@ from bleach.sanitizer import ALLOWED_TAGS import markdown import os from sqlalchemy import func -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, aliased from sqlalchemy.sql.functions import coalesce -from typing import Optional, Tuple +from typing import Optional, List, Dict, Tuple, Set import werkzeug.exceptions import wtforms from wtforms import validators, ValidationError from wtforms.widgets.html5 import NumberInput -import mo +import mo.config as config import mo.db as db import mo.imports -from mo.rights import Right, RoundRights +from mo.rights import Right import mo.util from mo.web import app import mo.web.fields as mo_fields -from mo.web.org_contest import ParticipantsActionForm, ParticipantsFilterForm, get_contestant_emails, get_contestants_query, make_contestant_table, \ - generic_import, generic_batch_download, generic_batch_upload, generic_batch_points - - -def get_round_rr(id: int, right_needed: Optional[Right], any_place: bool) -> Tuple[db.Round, db.Round, RoundRights]: - """Vrací round, master_round a Rights objekt pro zadané round_id. - Pro nedělená kola platí round == master_round. - Operace s účastníky by měly probíhat vždy přes master_round.""" - round = db.get_session().query(db.Round).options(joinedload(db.Round.master)).get(id) - if not round: - raise werkzeug.exceptions.NotFound() - - rr = g.gatekeeper.rights_for_round(round, any_place) - - if not (right_needed is None or rr.have_right(right_needed)): - raise werkzeug.exceptions.Forbidden() - - return round, round.master, rr - - -def get_task(round: db.Round, task_id: int) -> db.Task: - task = db.get_session().query(db.Task).get(task_id) - if not task or task.round_id != round.round_id: - raise werkzeug.exceptions.NotFound() - return task +from mo.web.org_contest import get_context @app.route('/org/contest/') def org_rounds(): sess = db.get_session() - rounds = sess.query(db.Round).filter_by(year=mo.current_year).order_by(db.Round.year, db.Round.category, db.Round.seq, db.Round.part) + rounds = sess.query(db.Round).filter_by(year=config.CURRENT_YEAR).order_by(db.Round.year, db.Round.category, db.Round.seq, db.Round.part) return render_template('org_rounds.html', rounds=rounds, history=False) @@ -181,71 +158,122 @@ def create_subcontests(master_round: db.Round, master_contest: db.Contest): app.logger.info(f"Podsoutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}") -@app.route('/org/contest/r/<int:id>/', methods=('GET', 'POST')) -def org_round(id: int): +@dataclass +class ContestStat: + region: db.Place + contest: Optional[db.Contest] = None + num_contests: int = 0 + contest_states: Set[db.RoundState] = field(default_factory=set) + num_active_pants: int = 0 + num_unconfirmed_pants: int = 0 + + +def region_stats(round: db.Round, region: db.Place) -> List[ContestStat]: + stats: Dict[int, ContestStat] = {} sess = db.get_session() - round, _, rr = get_round_rr(id, None, True) - - can_manage_round = rr.have_right(Right.manage_round) - can_manage_contestants = rr.have_right(Right.manage_contest) - - participants_count = sess.query( - db.Participation.contest_id, - func.count(db.Participation.user_id).label('count') - ).group_by(db.Participation.contest_id).subquery() - - # účastníci jsou jen pod master contesty - contests_counts = (sess.query( - db.Contest, - coalesce(participants_count.c.count, 0) - ).outerjoin(participants_count, db.Contest.master_contest_id == participants_count.c.contest_id) - .filter(db.Contest.round == round) - .options(joinedload(db.Contest.place)) - .all()) - - contests_counts.sort(key=lambda c: locale.strxfrm(c[0].place.name)) - - sol_counts_q = ( - sess.query(db.Solution.task_id, func.count(db.Solution.task_id)) - .filter(db.Solution.task_id.in_( - sess.query(db.Task.task_id).filter_by(round=round) - )) + + if region.level > round.level: + return [] + + if (region.level >= round.level - 1 + or region.level == 2 and round.level == 4): + # List individual contests + q = sess.query(db.Contest).filter_by(round=round) + q = db.filter_place_nth_parent(q, db.Contest.place_id, round.level - region.level, region.place_id) + q = q.options(joinedload(db.Contest.place)) + for c in q.all(): + s = ContestStat(region=c.place, contest=c, num_contests=1) + stats[c.place.place_id] = s + have_contests = True + else: + # List sub-regions + regs = sess.query(db.Place).filter(db.Place.parent_place == region).all() + for r in regs: + s = ContestStat(region=r) + stats[r.place_id] = s + have_contests = False + + region_ids = [s.region.place_id for s in stats.values()] + + if not have_contests: + rcs = (sess.query(db.RegionContestStat) + .filter_by(round=round) + .filter(db.RegionContestStat.region.in_(region_ids)) + .all()) + for r in rcs: + stats[r.region].num_contests += r.count + stats[r.region].contest_states.add(r.state) + + rs = (sess.query(db.RegionParticipantStat) + .filter_by(round_id=round.master_round_id) + .filter(db.RegionParticipantStat.region.in_(region_ids)) + .all()) + for r in rs: + if r.state == db.PartState.active: + stats[r.region].num_active_pants = r.count + elif r.state == db.PartState.registered: + stats[r.region].num_unconfirmed_pants = r.count + + out = list(stats.values()) + out.sort(key=lambda s: locale.strxfrm(s.region.name or "")) + return out + + +def region_totals(region: db.Place, stats: List[ContestStat]) -> ContestStat: + return ContestStat( + region=region, + num_contests=sum(s.num_contests for s in stats), + num_active_pants=sum(s.num_active_pants for s in stats), + num_unconfirmed_pants=sum(s.num_unconfirmed_pants for s in stats), ) - sol_counts = {} - for task_id, count in sol_counts_q.group_by(db.Solution.task_id).all(): - sol_counts[task_id] = count +def task_stats(round: db.Round, region: db.Place) -> List[Tuple[db.Task, int]]: + sess = db.get_session() tasks = sess.query(db.Task).filter_by(round=round).all() tasks.sort(key=lambda t: t.code) - for task in tasks: - task.sol_count = sol_counts[task.task_id] if task.task_id in sol_counts else 0 + + ts = (sess.query(db.RegionTaskStat) + .filter_by(round=round, region=region.place_id) + .all()) + count_by_id = {s.task_id: s.count for s in ts} + + return [(t, count_by_id.get(t.task_id, 0)) for t in tasks] + + +@app.route('/org/contest/r/<int:round_id>/', methods=('GET', 'POST')) +@app.route('/org/contest/r/<int:round_id>/h/<int:hier_id>', methods=('GET', 'POST')) +def org_round(round_id: int, hier_id: Optional[int] = None): + ctx = get_context(round_id=round_id, hier_id=hier_id) + round = ctx.round + rights = ctx.rights form_delete_task = TaskDeleteForm() - if can_manage_round and delete_task(id, form_delete_task): - return redirect(url_for('org_round', id=id)) + if rights.have_right(Right.manage_round) and delete_task(round_id, form_delete_task): + return redirect(ctx.url_for('org_round')) form_add_contest = AddContestForm() form_add_contest.place.label.text = "Nová soutěž " + round.get_level().in_name() if add_contest(round, form_add_contest): - return redirect(url_for('org_round', id=id)) + return redirect(ctx.url_for('org_round')) group_rounds = round.get_group_rounds(True) group_rounds.sort(key=lambda r: r.round_code()) + region = ctx.hier_place or db.get_root_place() + reg_stats = region_stats(round, region) + reg_total = region_totals(region, reg_stats) + task_info = task_stats(round, region) + return render_template( 'org_round.html', + ctx=ctx, rights=rights, round=round, group_rounds=group_rounds, - roles=[r.friendly_name() for r in rr.get_roles()], - contests_counts=contests_counts, - tasks=tasks, form_delete_task=form_delete_task, + roles=[r.friendly_name() for r in rights.get_roles()], + reg_stats=reg_stats, reg_total=reg_total, + task_info=task_info, + form_delete_task=form_delete_task, form_add_contest=form_add_contest, - can_manage_round=can_manage_round, - can_manage_contestants=can_manage_contestants, - can_handle_submits=rr.have_right(Right.view_submits), - can_upload=rr.offer_upload_feedback(), - can_view_statement=rr.can_view_statement(), - can_add_contest=g.gatekeeper.rights_generic().have_right(Right.add_contest), statement_exists=mo.web.util.task_statement_exists(round), ) @@ -267,18 +295,18 @@ class TaskEditForm(FlaskForm): self.max_points.widget = NumberInput(min=0, step=points_step) -@app.route('/org/contest/r/<int:id>/task/new', methods=('GET', 'POST')) -def org_round_task_new(id: int): +@app.route('/org/contest/r/<int:round_id>/task/new', methods=('GET', 'POST')) +def org_round_task_new(round_id: int): sess = db.get_session() - round, master_round, _ = get_round_rr(id, Right.manage_round, True) + ctx = get_context(round_id=round_id, right_needed=Right.manage_round) - form = TaskEditForm(master_round.points_step) + form = TaskEditForm(ctx.master_round.points_step) if form.validate_on_submit(): task = db.Task() - task.round = round + task.round = ctx.round form.populate_obj(task) - if sess.query(db.Task).filter_by(round_id=id, code=task.code).first(): + if sess.query(db.Task).filter_by(round_id=round_id, code=task.code).first(): flash('Úloha se stejným kódem již v tomto kole existuje', 'danger') else: sess.add(task) @@ -291,28 +319,25 @@ def org_round_task_new(id: int): sess.commit() app.logger.info(f"Úloha {task.code} ({task.task_id}) přidána: {db.row2dict(task)}") flash('Nová úloha přidána', 'success') - return redirect(url_for('org_round', id=id)) + return redirect(ctx.url_for('org_round')) return render_template( 'org_round_task_edit.html', - round=round, task=None, form=form, + ctx=ctx, form=form, ) -@app.route('/org/contest/r/<int:id>/task/<int:task_id>/edit', methods=('GET', 'POST')) -def org_round_task_edit(id: int, task_id: int): +@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/edit', methods=('GET', 'POST')) +def org_round_task_edit(round_id: int, task_id: int): sess = db.get_session() - round, master_round, _ = get_round_rr(id, Right.manage_round, True) + ctx = get_context(round_id=round_id, task_id=task_id, right_needed=Right.manage_round) + task = ctx.task + assert task - task = sess.query(db.Task).get(task_id) - # FIXME: Check contest! - if not task: - raise werkzeug.exceptions.NotFound() - - form = TaskEditForm(master_round.points_step, obj=task) + form = TaskEditForm(ctx.master_round.points_step, obj=task) if form.validate_on_submit(): if sess.query(db.Task).filter( - db.Task.task_id != task_id, db.Task.round_id == id, db.Task.code == form.code.data + db.Task.task_id != task_id, db.Task.round_id == round_id, db.Task.code == form.code.data ).first(): flash('Úloha se stejným kódem již v tomto kole existuje', 'danger') else: @@ -329,92 +354,15 @@ def org_round_task_edit(id: int, task_id: int): app.logger.info(f"Úloha {task.code} ({task_id}) modifikována, změny: {changes}") flash('Změny úlohy uloženy', 'success') else: - flash(u'Žádné změny k uložení', 'info') + flash('Žádné změny k uložení', 'info') - return redirect(url_for('org_round', id=id)) + return redirect(ctx.url_for('org_round', task_id=None)) return render_template( 'org_round_task_edit.html', - round=round, task=task, form=form, - ) - - -@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/download', methods=('GET', 'POST')) -def org_round_task_download(round_id: int, task_id: int): - round, _, _ = get_round_rr(round_id, Right.view_submits, False) - task = get_task(round, task_id) - return generic_batch_download(round=round, contest=None, site=None, task=task) - - -@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/upload', methods=('GET', 'POST')) -def org_round_task_upload(round_id: int, task_id: int): - round, _, rr = get_round_rr(round_id, Right.view_submits, False) - task = get_task(round, task_id) - return generic_batch_upload(round=round, contest=None, site=None, task=task, - offer_upload_solutions=rr.offer_upload_solutions(), - offer_upload_feedback=rr.offer_upload_feedback()) - - -@app.route('/org/contest/r/<int:round_id>/task/<int:task_id>/batch-points', methods=('GET', 'POST')) -def org_round_task_batch_points(round_id: int, task_id: int): - round, _, _ = get_round_rr(round_id, Right.edit_points, True) - task = get_task(round, task_id) - return generic_batch_points(round=round, contest=None, task=task) - - -@app.route('/org/contest/r/<int:id>/list', methods=('GET', 'POST')) -@app.route('/org/contest/r/<int:id>/list/emails', endpoint="org_round_list_emails") -def org_round_list(id: int): - round, master_round, rr = get_round_rr(id, Right.view_contestants, True) - can_edit = rr.have_right(Right.manage_round) and request.endpoint != 'org_round_list_emails' - format = request.args.get('format', "") - - filter = ParticipantsFilterForm(formdata=request.args) - filter.validate() - query = get_contestants_query( - round=master_round, - school=filter.school.place, - contest_place=filter.contest_place.place, - participation_place=filter.participation_place.place, - participation_state=mo.util.star_is_none(filter.participation_state.data), + ctx=ctx, form=form, ) - action_form = None - if can_edit: - action_form = ParticipantsActionForm() - if action_form.do_action(round=master_round, query=query): - # Action happened, redirect - return redirect(request.url) - - if format == "": - table = None - emails = None - mailto_link = None - if request.endpoint == 'org_round_list_emails': - (emails, mailto_link) = get_contestant_emails(query, - mailto_subject=f'{round.name} kategorie {round.category}') - count = len(emails) - else: - (count, query) = filter.apply_limits(query, pagesize=50) - # count = db.get_count(query) - table = make_contestant_table(query, round, add_contest_column=True, add_checkbox=True) - - return render_template( - 'org_round_list.html', - round=round, - table=table, emails=emails, mailto_link=mailto_link, - filter=filter, count=count, action_form=action_form, - ) - else: - table = make_contestant_table(query, round, is_export=True) - return table.send_as(format) - - -@app.route('/org/contest/r/<int:id>/import', methods=('GET', 'POST')) -def org_round_import(id: int): - round, master_round, rr = get_round_rr(id, Right.manage_contest, True) - return generic_import(round, master_round, None, None) - class RoundEditForm(FlaskForm): _for_round: Optional[db.Round] = None @@ -469,10 +417,11 @@ class RoundEditForm(FlaskForm): self.abstract_validate_time_order(field) -@app.route('/org/contest/r/<int:id>/edit', methods=('GET', 'POST')) -def org_round_edit(id: int): +@app.route('/org/contest/r/<int:round_id>/edit', methods=('GET', 'POST')) +def org_round_edit(round_id: int): sess = db.get_session() - round, _, rr = get_round_rr(id, Right.manage_round, True) + ctx = get_context(round_id=round_id, right_needed=Right.manage_round) + round = ctx.round form = RoundEditForm(obj=round) form._for_round = round @@ -490,10 +439,10 @@ def org_round_edit(id: int): if sess.is_modified(round): changes = db.get_object_changes(round) - app.logger.info(f"Round #{id} modified, changes: {changes}") + app.logger.info(f"Round #{round_id} modified, changes: {changes}") mo.util.log( type=db.LogType.round, - what=id, + what=round_id, details={'action': 'edit', 'changes': changes}, ) @@ -512,26 +461,27 @@ def org_round_edit(id: int): sess.commit() flash('Změny kola uloženy', 'success') else: - flash(u'Žádné změny k uložení', 'info') + flash('Žádné změny k uložení', 'info') - return redirect(url_for('org_round', id=id)) + return redirect(ctx.url_for('org_round')) return render_template( 'org_round_edit.html', + ctx=ctx, round=round, form=form, ) -@app.route('/org/contest/r/<int:id>/task-statement/zadani.pdf') -def org_task_statement(id: int): - round, _, rr = get_round_rr(id, None, True) +@app.route('/org/contest/r/<int:round_id>/task-statement/zadani.pdf') +def org_task_statement(round_id: int): + ctx = get_context(round_id=round_id) - if not rr.can_view_statement(): + if not ctx.rights.can_view_statement(): app.logger.warn(f'Organizátor #{g.user.user_id} chce zadání, na které nemá právo') raise werkzeug.exceptions.Forbidden() - return mo.web.util.send_task_statement(round) + return mo.web.util.send_task_statement(ctx.round) class StatementEditForm(FlaskForm): @@ -540,18 +490,19 @@ class StatementEditForm(FlaskForm): delete = wtforms.SubmitField('Smazat') -@app.route('/org/contest/r/<int:id>/task-statement/edit', methods=('GET', 'POST')) -def org_edit_statement(id: int): +@app.route('/org/contest/r/<int:round_id>/task-statement/edit', methods=('GET', 'POST')) +def org_edit_statement(round_id: int): sess = db.get_session() - round, _, rr = get_round_rr(id, Right.manage_round, True) + ctx = get_context(round_id=round_id, right_needed=Right.manage_round) + round = ctx.round def log_changes(): if sess.is_modified(round): changes = db.get_object_changes(round) - app.logger.info(f"Kolo #{id} změněno, změny: {changes}") + app.logger.info(f"Kolo #{round_id} změněno, změny: {changes}") mo.util.log( type=db.LogType.round, - what=id, + what=round_id, details={'action': 'edit', 'changes': changes}, ) @@ -572,7 +523,7 @@ def org_edit_statement(id: int): log_changes() sess.commit() flash('Zadání nahráno', 'success') - return redirect(url_for('org_round', id=id)) + return redirect(ctx.url_for('org_round')) else: flash('Vyberte si prosím soubor', 'danger') if form.delete.data: @@ -580,10 +531,11 @@ def org_edit_statement(id: int): log_changes() sess.commit() flash('Zadání smazáno', 'success') - return redirect(url_for('org_round', id=id)) + return redirect(ctx.url_for('org_round')) return render_template( 'org_edit_statement.html', + ctx=ctx, round=round, form=form, ) @@ -605,38 +557,39 @@ class MessageRemoveForm(FlaskForm): message_remove = wtforms.SubmitField() -@app.route('/org/contest/r/<int:id>/messages/', methods=('GET', 'POST')) -def org_round_messages(id: int): +@app.route('/org/contest/r/<int:round_id>/messages/', methods=('GET', 'POST')) +def org_round_messages(round_id: int): sess = db.get_session() - round, _, rr = get_round_rr(id, None, True) + ctx = get_context(round_id=round_id) + round = ctx.round if not round.has_messages: flash('Toto kolo nemá aktivní zprávičky pro účastníky, aktivujte je v nastavení kola', 'warning') - return redirect(url_for('org_round', id=id)) + return redirect(ctx.url_for('org_round')) - messages = sess.query(db.Message).filter_by(round_id=id).order_by(db.Message.created_at).all() + messages = sess.query(db.Message).filter_by(round_id=round_id).order_by(db.Message.created_at).all() add_form: Optional[MessageAddForm] = None remove_form: Optional[MessageRemoveForm] = None preview: Optional[db.Message] = None - if rr.have_right(Right.manage_round): + if ctx.rights.have_right(Right.manage_round): add_form = MessageAddForm() remove_form = MessageRemoveForm() if remove_form.validate_on_submit() and remove_form.message_remove.data: msg = sess.query(db.Message).get(remove_form.message_id.data) - if not msg or msg.round_id != id: + if not msg or msg.round_id != round_id: raise werkzeug.exceptions.NotFound() sess.delete(msg) sess.commit() - app.logger.info(f"Zprávička pro kolo {id} odstraněna: {db.row2dict(msg)}") + app.logger.info(f"Zprávička pro kolo {round_id} odstraněna: {db.row2dict(msg)}") flash('Zprávička odstraněna', 'success') - return redirect(url_for('org_round_messages', id=id)) + return redirect(ctx.url_for('org_round_messages')) if add_form.validate_on_submit(): msg = db.Message( - round_id=id, + round_id=round_id, created_by=g.user.user_id, created_at=mo.now, ) @@ -651,14 +604,15 @@ def org_round_messages(id: int): elif add_form.submit.data: sess.add(msg) sess.commit() - app.logger.info(f"Vložena nová zprávička pro kolo {id}: {db.row2dict(msg)}") + app.logger.info(f"Vložena nová zprávička pro kolo {round_id}: {db.row2dict(msg)}") flash('Zprávička úspěšně vložena', 'success') - return redirect(url_for('org_round_messages', id=id)) + return redirect(ctx.url_for('org_round_messages')) return render_template( 'org_round_messages.html', - round=round, rr=rr, messages=messages, + ctx=ctx, + round=round, messages=messages, add_form=add_form, remove_form=remove_form, preview=preview, ) diff --git a/mo/web/org_score.py b/mo/web/org_score.py index 62932ab90a2714e90624d266ab31a5be971d14e4..2fb1585df69b07898b70181329cb231df8212298 100644 --- a/mo/web/org_score.py +++ b/mo/web/org_score.py @@ -9,6 +9,7 @@ import mo.db as db from mo.rights import Right from mo.score import Score from mo.web import app +from mo.web.org_contest import get_context from mo.web.table import Cell, CellLink, Column, Row, Table, cell_pion_link from mo.util_format import format_decimal @@ -42,11 +43,13 @@ class SolPointsCell(Cell): contest_id: int user: db.User sol: Optional[db.Solution] + link_to_paper: bool - def __init__(self, contest_id: int, user: db.User, sol: Optional[db.Solution]): + def __init__(self, contest_id: int, user: db.User, sol: Optional[db.Solution], link_to_paper: bool): self.contest_id = contest_id self.user = user self.sol = sol + self.link_to_paper = link_to_paper def __str__(self) -> str: if not self.sol: @@ -63,7 +66,9 @@ class SolPointsCell(Cell): else: points = format_decimal(self.sol.points) - if self.sol.final_feedback_obj: + if not self.link_to_paper: + return f'<td>{points}' + elif self.sol.final_feedback_obj: url = mo.web.util.org_paper_link(self.contest_id, None, self.user, self.sol.final_feedback_obj) return f'<td><a href="{url}" title="Zobrazit finální opravu">{points}</a>' elif self.sol.final_submit_obj: @@ -74,34 +79,18 @@ class SolPointsCell(Cell): @app.route('/org/contest/r/<int:round_id>/score') -@app.route('/org/contest/c/<int:contest_id>/score') -def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): - if round_id is None and contest_id is None: - raise werkzeug.exceptions.BadRequest() - if round_id is not None and contest_id is not None: - raise werkzeug.exceptions.BadRequest() +@app.route('/org/contest/c/<int:ct_id>/score') +def org_score(round_id: Optional[int] = None, ct_id: Optional[int] = None): + ctx = get_context(round_id=round_id, ct_id=ct_id) + contest = ctx.contest + round = ctx.round format = request.args.get('format', "") sess = db.get_session() - if round_id: - contest = None - round = sess.query(db.Round).options( - joinedload(db.Round.master) - ).get(round_id) - if not round: - raise werkzeug.exceptions.NotFound() - rr = g.gatekeeper.rights_for_round(round, True) - else: - contest = sess.query(db.Contest).options( - joinedload(db.Contest.round).joinedload(db.Round.master) - ).get(contest_id) - if not contest: - raise werkzeug.exceptions.NotFound() - round = contest.round - rr = g.gatekeeper.rights_for_contest(contest) - - if not rr.have_right(Right.view_submits): + + if not ctx.rights.have_right(Right.view_contestants): raise werkzeug.exceptions.Forbidden() + can_view_submits = ctx.rights.have_right(Right.view_submits) score = Score(round.master, contest) tasks = score.get_tasks() @@ -127,7 +116,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): columns.append(Column(key='participant', name='ucastnik', title='Účastník')) if is_export: columns.append(Column(key='email', name='email')) - if not contest_id: + if not ct_id: columns.append(Column(key='contest', name='oblast', title=round.get_level().name.title())) if is_export: columns.append(Column(key='pion_place', name='soutezni_misto')) @@ -140,12 +129,12 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): if contest: local_ct_id = subcontest_id_map[(task.round_id, contest.master_contest_id)] title = '<a href="{}">{}</a>'.format( - url_for('org_contest_task', contest_id=local_ct_id, task_id=task.task_id), + url_for('org_contest_task', ct_id=local_ct_id, task_id=task.task_id), task.code ) - if rr.can_edit_points(): + if ctx.rights.can_edit_points(): title += ' <a href="{}" title="Editovat body" class="icon">✎</a>'.format( - url_for('org_contest_task_points', contest_id=local_ct_id, task_id=task.task_id), + url_for('org_contest_task_points', ct_id=local_ct_id, task_id=task.task_id), ) columns.append(Column(key=f'task_{task.task_id}', name=task.code, title=title)) columns.append(Column(key='total_points', name='celkove_body', title='Celkové body')) @@ -177,7 +166,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): 'user': user, 'email': user.email, 'participant': cell_pion_link(user, local_pion_ct_id, user.full_name()), - 'contest': CellLink(pion.contest.place.name or "?", url_for('org_contest', id=pion.contest_id)), + 'contest': CellLink(pion.contest.place.name or "?", url_for('org_contest', ct_id=pion.contest_id)), 'pion_place': pion.place.name, 'school': CellLink(school.name or "?", url_for('org_place', id=school.place_id)), 'grade': pant.grade, @@ -190,7 +179,8 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): for task in tasks: local_sol_ct_id = subcontest_id_map[(task.round_id, pion.contest_id)] row.keys[f'task_{task.task_id}'] = SolPointsCell( - contest_id=local_sol_ct_id, user=user, sol=sols.get(task.task_id) + contest_id=local_sol_ct_id, user=user, sol=sols.get(task.task_id), + link_to_paper=can_view_submits ) if result.winner: row.html_attr = {"class": "winner", "title": "Vítěz"} @@ -214,6 +204,7 @@ def org_score(round_id: Optional[int] = None, contest_id: Optional[int] = None): if format == "": return render_template( 'org_score.html', + ctx=ctx, contest=contest, round=round, tasks=tasks, table=table, messages=messages, group_rounds=group_rounds, diff --git a/mo/web/org_users.py b/mo/web/org_users.py index b549096bc88cd5d154e6c9681c5458d76dc9ac43..74335fab5302cd4433c2c7e3f28d6db61129e4c5 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -190,7 +190,7 @@ def org_orgs(): if filter.search_in_place.place is not None: qr = qr.filter(db.UserRole.place_id.in_(db.place_descendant_cte(filter.search_in_place.place))) if filter.search_right_for_place.place is not None: - qr = qr.filter(db.UserRole.place_id.in_([x.place_id for x in db.get_place_parents(filter.search_right_for_place.place)])) + qr = qr.filter(db.UserRole.place_id.in_([x.place_id for x in db.get_place_ancestors(filter.search_right_for_place.place)])) # Po n>3 hodinách v mo.db jsem dospěl k závěru, že to hezčeji neumím (neumím vyrobit place_parents_cte) if filter.search_place_level.data: qr = qr.filter(db.UserRole.place_id.in_( diff --git a/mo/web/table.py b/mo/web/table.py index 72cbbcd14fdf0c7c501ccdb610dcbc6d52ec3543..37d61d86bc30c78683bf59f5d4cd4b38f8ee2000 100644 --- a/mo/web/table.py +++ b/mo/web/table.py @@ -210,7 +210,7 @@ def cell_user_link(user: db.User, text: str) -> CellLink: def cell_pion_link(user: db.User, contest_id: int, text: str) -> CellLink: - return CellLink(text, url_for('org_contest_user', contest_id=contest_id, user_id=user.user_id)) + return CellLink(text, url_for('org_contest_user', ct_id=contest_id, user_id=user.user_id)) def cell_place_link(place: db.Place, text: str) -> CellLink: diff --git a/mo/web/templates/org_contest.html b/mo/web/templates/org_contest.html index f78ea9c2fbaaf0635885c1abef6ba9f3a06d6c0a..0042d9ede74b752ee18fabeee5c614b2f87fbf78 100644 --- a/mo/web/templates/org_contest.html +++ b/mo/web/templates/org_contest.html @@ -2,13 +2,17 @@ {% set round = contest.round %} {% set state = contest.state %} {% set ct_state = contest.ct_state() %} -{% set site_id = site.place_id if site else None %} +{% set can_manage = rights.have_right(Right.manage_contest) %} +{% set can_upload = rights.can_upload_feedback() %} +{% set can_edit_points = rights.can_edit_points() %} +{% set can_create_solutions = rights.can_upload_feedback() or rights.can_upload_solutions() %} +{% set can_view_statement = rights.can_view_statement() %} {% block title %} {{ round.round_code() }}: {% if site %}soutěžní místo {{ site.name }}{% else %}{{ contest.place.name }}{% endif %} {% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, site=site) }} +{{ ctx.breadcrumbs() }} {% endblock %} {% block body %} @@ -27,7 +31,7 @@ {% if group_contests|length > 1 %} <tr><td>Soutěže ve skupině kol:<td> {% for c in group_contests %} - {% if c == contest %}<i>{% else %}<a href="{{ url_for('org_contest', id=c.contest_id) }}">{% endif %} + {% if c == contest %}<i>{% else %}<a href="{{ url_for('org_contest', ct_id=c.contest_id) }}">{% endif %} {{ c.round.round_code() }}: {% if site %}soutěžní místo {{ site.name }}{% else %}{{ contest.place.name }}{% endif %} {% if c == contest %} (tato soutěž)</i>{% else %}</a>{% endif %}<br> {% endfor %} @@ -35,7 +39,7 @@ <tr><td>Zadání<td> {% if round.tasks_file %} {% if can_view_statement %} - <a href='{{ url_for('org_task_statement', id=round.round_id) }}'>stáhnout</a> + <a href='{{ ctx.url_for('org_task_statement', ct_id=None) }}'>stáhnout</a> {% else %} není dostupné {% endif %} @@ -45,28 +49,28 @@ </table> <div class="btn-group"> - <a class="btn btn-primary" href='{{ url_for('org_contest_list', id=contest.contest_id, site_id=site_id) }}'>Seznam účastníků</a> + <a class="btn btn-primary" href='{{ ctx.url_for('org_generic_list') }}'>Seznam účastníků</a> {% if state != RoundState.preparing %} - <a class="btn btn-primary" href='{{ url_for('org_contest_solutions', id=contest.contest_id, site_id=site_id) }}'>Odevzdaná řešení</a> + <a class="btn btn-primary" href='{{ ctx.url_for('org_contest_solutions') }}'>Odevzdaná řešení</a> {% endif %} {% if can_manage and site %} - <a class="btn btn-default" href="{{ url_for('org_contest_add_user', id=contest.contest_id, site_id=site_id) }}">Přidat účastníka</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_contest_add_user') }}">Přidat účastníka</a> {% endif %} {% if not site %} {% if state in [RoundState.grading, RoundState.closed] %} - <a class="btn btn-primary" href='{{ url_for('org_score', contest_id=contest.contest_id) }}'>Výsledky</a> + <a class="btn btn-primary" href='{{ ctx.url_for('org_score') }}'>Výsledky</a> {% endif %} {% if state == RoundState.preparing and round.seq > 1 %} - <a class="btn btn-primary" href='{{ url_for('org_contest_advance', contest_id=contest.contest_id) }}'>Postup z minulého kola</a> + <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='{{ url_for('org_contest_import', id=contest.contest_id) }}'>Importovat data</a> + <a class="btn btn-default" href='{{ ctx.url_for('org_generic_import') }}'>Importovat data</a> {% endif %} {% if can_manage and not site %} - <a class="btn btn-default" href='{{ url_for('org_contest_edit', id=contest.contest_id) }}'>Nastavení</a> + <a class="btn btn-default" href='{{ ctx.url_for('org_contest_edit') }}'>Nastavení</a> {% endif %} {% if g.user.is_admin %} - <a class="btn btn-default" href="{{ log_url('contest', contest.contest_id) }}">Historie</a> + <a class="btn btn-default" href="{{ log_url('contest', ctx.ct_id) }}">Historie</a> {% endif %} {% endif %} </div> @@ -80,12 +84,12 @@ </thead> {% for (place, count) in places_counts %} <tr> - <td><a href="{{ url_for('org_contest', id=contest.contest_id, site_id=place.place_id) }}">{{ place.name }}</a> + <td><a href="{{ ctx.url_for('org_contest', site_id=place.place_id) }}">{{ place.name }}</a> <td>{{ count }} <td><div class="btn-group"> - <a class="btn btn-xs btn-primary" href="{{ url_for('org_contest', id=contest.contest_id, site_id=place.place_id) }}">Detail</a> + <a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_contest', site_id=place.place_id) }}">Detail</a> {% if can_manage %} - <a class="btn btn-xs btn-default" href="{{ url_for('org_contest_add_user', id=contest.contest_id, site_id=place.place_id) }}">Přidat účastníka</a> + <a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_contest_add_user', site_id=place.place_id) }}">Přidat účastníka</a> </div> {% endif %} </tr> @@ -104,7 +108,7 @@ {% endif %} <div class="btn-group"> {% if can_manage and not site %} - <a class="btn btn-default" href='{{ url_for('org_contest_add_user', id=contest.contest_id) }}'>Přidat účastníka</a> + <a class="btn btn-default" href='{{ ctx.url_for('org_contest_add_user') }}'>Přidat účastníka</a> {% endif %} </div> @@ -128,21 +132,21 @@ <td>{{ task.sol_count }} <td>{{ task.max_points|decimal|none_value('–') }} <td><div class="btn-group"> - <a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_task', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Odevzdaná řešení</a> + <a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_contest_task', task_id=task.task_id) }}">Odevzdaná řešení</a> {% if not site and can_edit_points %} - <a class="btn btn-xs btn-default" href="{{ url_for('org_contest_task_points', contest_id=contest.contest_id, task_id=task.task_id) }}">Zadat body</a> + <a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_contest_task_points', task_id=task.task_id) }}">Zadat body</a> {% endif %} {% if can_create_solutions %} - <a class="btn btn-xs btn-default" href="{{ url_for('org_contest_task_create', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Založit řešení</a> + <a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_contest_task_create', task_id=task.task_id) }}">Založit řešení</a> {% endif %} </div> <td><div class="btn-group"> - <a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_task_download', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Stáhnout ZIP</a> + <a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_generic_batch_download', task_id=task.task_id) }}">Stáhnout ZIP</a> {% if can_upload %} - <a class='btn btn-xs btn-default' href="{{ url_for('org_contest_task_upload', contest_id=contest.contest_id, task_id=task.task_id, site_id=site_id) }}">Nahrát ZIP</a> + <a class='btn btn-xs btn-default' href="{{ ctx.url_for('org_generic_batch_upload', task_id=task.task_id) }}">Nahrát ZIP</a> {% endif %} {% if not site and can_edit_points %} - <a class="btn btn-xs btn-default" href="{{ url_for('org_contest_task_batch_points', contest_id=contest.contest_id, task_id=task.task_id) }}">Nahrát body</a> + <a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_generic_batch_points', task_id=task.task_id) }}">Nahrát body</a> {% endif %} </div> </tr> @@ -156,8 +160,8 @@ Práva k {% if site %}soutěžními místu{% else %}soutěži{% endif %}: {% if g.user.is_admin %} admin -{% elif rights %} - {% for r in rights %} +{% elif rights_list %} + {% for r in rights_list %} {{ r.name }} {% endfor %} {% else %} diff --git a/mo/web/templates/org_contest_add_user.html b/mo/web/templates/org_contest_add_user.html index d544dbef1c6d75fe4f0bfc8b93788a3fb0a6cf33..fe181f2379b20d2247f56a900b49dcae74bbf22c 100644 --- a/mo/web/templates/org_contest_add_user.html +++ b/mo/web/templates/org_contest_add_user.html @@ -1,12 +1,11 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% set round = contest.round %} {% block title %} {{ round.round_code() }}: Přidat účastníka {% if site %}do soutěžního místa {{ site.name }}{% else %}do oblasti {{ contest.place.name }}{% endif %} {% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Přidat účastníka") }} +{{ ctx.breadcrumbs(action="Přidat účastníka") }} {% endblock %} {% block body %} diff --git a/mo/web/templates/org_contest_advance.html b/mo/web/templates/org_contest_advance.html index 2094c11ac51a31f771947e5a2dc0c862ffba0127..b6f22bdac3ba06ec6063ef3b93acc4a210e05552 100644 --- a/mo/web/templates/org_contest_advance.html +++ b/mo/web/templates/org_contest_advance.html @@ -3,7 +3,7 @@ {% block title %}Postup z {{ prev_round.round_code() }} ({{ prev_round.name }}) do {{ round.round_code() }} ({{ round.name }}){% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, action="Postup") }} +{{ ctx.breadcrumbs(action="Postup") }} {% endblock %} {% block body %} @@ -25,10 +25,10 @@ <tbody> {% for c in prev_contests %} <tr> - <td><a href='{{ url_for('org_contest', id=c.contest_id) }}'>{{ c.place.name }}</a> + <td><a href='{{ url_for('org_contest', ct_id=c.contest_id) }}'>{{ c.place.name }}</a> <td>{{ accept_by_place_id[c.place.place_id] }} <td>{{ reject_by_place_id[c.place.place_id] }} - <td><a class='btn btn-warning btn-xs' href='{{ url_for('org_score', contest_id=c.contest_id) }}'>Výsledková listina</a> + <td><a class='btn btn-warning btn-xs' href='{{ url_for('org_score', ct_id=c.contest_id) }}'>Výsledková listina</a> {% endfor %} <tfoot> <tr> diff --git a/mo/web/templates/org_contest_edit.html b/mo/web/templates/org_contest_edit.html index 508d75fd58a9e51030aa0f60d625ed459405463c..39e4c0fb729dba67e6d6786387dbf5f43873b232 100644 --- a/mo/web/templates/org_contest_edit.html +++ b/mo/web/templates/org_contest_edit.html @@ -3,7 +3,7 @@ {% block title %}Editace soutěže {{ round.round_code() }}: {{ contest.place.name }}{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, action="Editace") }} +{{ ctx.breadcrumbs(action="Editace") }} {% endblock %} {% block body %} diff --git a/mo/web/templates/org_contest_list.html b/mo/web/templates/org_contest_list.html deleted file mode 100644 index e8b01899f8b27ce3ed879e8259d7adde140be7a7..0000000000000000000000000000000000000000 --- a/mo/web/templates/org_contest_list.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends "base.html" %} -{% import "bootstrap/wtf.html" as wtf %} -{% set round = contest.round %} - -{% block title %} -Seznam účastníků {% if site %}v soutěžním místě {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %} -{% endblock %} -{% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Seznam účastníků" if table else "E-maily", table=False if table else True) }} -{% endblock %} -{% set id = contest.contest_id %} -{% set site_id = site.place_id if site else None %} - -{% block body %} -<div class="form-frame"> -<form action="" method="GET" class="form form-inline" role="form"> - <div class="form-row"> - {% if not site %} - {{ wtf.form_field(filter.participation_place, size=8) }} - {% endif %} - {{ wtf.form_field(filter.school, size=8) }} - {{ wtf.form_field(filter.participation_state) }} - <div class="btn-group"> - {{ wtf.form_field(filter.submit, class='btn btn-primary') }} - {% if table %} - <button class="btn btn-default" name="format" value="cs_csv" title="Stáhnout celý výsledek v CSV">↓ CSV</button> - <button class="btn btn-default" name="format" value="tsv" title="Stáhnout celý výsledek v TSV">↓ TSV</button> - {% endif %} - </div> - {% if not site %} - </div> - <div class="form-row" style="margin-top: 5px;"> - {% endif %} - Celkem <b>{{count|inflected('nalezený účastník', 'nalezení účastníci', 'nalezených účastníků')}}</b>. - </div> -</form> -</div> - -{% if table %} - {% include 'parts/org_participants_table_actions.html' %} -{% else %} - {% include 'parts/org_participants_emails.html' %} -{% endif %} -{% endblock %} diff --git a/mo/web/templates/org_contest_solutions.html b/mo/web/templates/org_contest_solutions.html index 7df83f66e70e2cf897b4b8c8fd0e841f129dbb05..20bbd1ac58d7511eff040d90e3335d7327df6967 100644 --- a/mo/web/templates/org_contest_solutions.html +++ b/mo/web/templates/org_contest_solutions.html @@ -1,20 +1,19 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} {% set round = contest.round %} -{% set site_id = site.place_id if site else None %} {% block title %} {{ "Založení řešení" if edit_form else "Tabulka řešení" }} {% if site %}soutěžního místa {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %} {% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, site=site, action="Založení řešení" if edit_form else "Tabulka řešení") }} +{{ ctx.breadcrumbs(action="Založení řešení" if edit_form else "Tabulka řešení") }} {% endblock %} {% block pretitle %} {% if contest.state in [RoundState.grading, RoundState.closed] %} <div class="btn-group pull-right"> - <a class="btn btn-default" href="{{ url_for('org_score', contest_id=contest.contest_id) }}">Výsledky {{ round.get_level().name_genitive() }}</a> - <a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky kola</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_score') }}">Výsledky {{ round.get_level().name_genitive() }}</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_score', ct_id=None) }}">Výsledky kola</a> </div> {% endif %} {% endblock %} @@ -48,9 +47,9 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje <th rowspan=2>Účastník <th rowspan=2>Stav účasti</th> {% for task in tasks %}<th colspan=4> - <a href="{{ url_for('org_contest_task', contest_id=contest.contest_id, site_id=site_id, task_id=task.task_id) }}">{{ task.code }}</a> - {% if sc.allow_edit_points %} - <a title="Editovat body" href="{{ url_for('org_contest_task_points', contest_id=contest.contest_id, task_id=task.task_id) }}" class="icon pull-right">✎</a> + <a href="{{ ctx.url_for('org_contest_task', task_id=task.task_id) }}">{{ task.code }}</a> + {% if rights.can_edit_points() %} + <a title="Editovat body" href="{{ ctx.url_for('org_contest_task_points', task_id=task.task_id) }}" class="icon pull-right">✎</a> {% endif %} {% endfor %} <th rowspan=2>Body celkem @@ -61,7 +60,7 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje </thead> {% for pion in pions %} {% set u = pion.user %} - <tr class="state-{{ pion.state.name }}> + <tr class="state-{{ pion.state.name }}"> <th>{{ u|pion_link(contest.contest_id) }} <td>{{ pion.state.friendly_name() }} {% set sum_points = [] %} @@ -109,7 +108,7 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje {% else %}–{% endif %} <td> {% endif %} - <a class="btn btn-xs btn-link icon" title="Detail řešení" href="{{ url_for('org_submit_list', contest_id=contest.contest_id, user_id=u.user_id, task_id=task.task_id, site_id=site_id) }}">🔍</a> + <a class="btn btn-xs btn-link icon" title="Detail řešení" href="{{ ctx.url_for('org_submit_list', user_id=u.user_id, task_id=task.task_id) }}">🔍</a> {% endfor %} <th>{{ sum_points|sum|decimal }}</th> </tr> @@ -118,9 +117,9 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje <tr><td><td> {% for task in tasks %} <td colspan=4><div class='btn-group'> - <a class='btn btn-xs btn-primary' href="{{ url_for('org_contest_task_download', contest_id=contest.contest_id, site_id=site_id, task_id=task.task_id) }}">Stáhnout</a> - {% if sc.allow_upload_feedback %} - <a class='btn btn-xs btn-primary' href="{{ url_for('org_contest_task_upload', contest_id=contest.contest_id, site_id=site_id, task_id=task.task_id) }}">Nahrát</a> + <a class='btn btn-xs btn-primary' href="{{ ctx.url_for('org_generic_batch_download', task_id=task.task_id) }}">Stáhnout</a> + {% if rights.can_upload_feedback() %} + <a class='btn btn-xs btn-primary' href="{{ ctx.url_for('org_generic_batch_upload', task_id=task.task_id) }}">Nahrát</a> {% endif %} </div> {% endfor %} @@ -130,13 +129,13 @@ konkrétní úlohu. Symbol <span class="icon">🗐</span> značí, že existuje {% if edit_form %} <div class='btn-group'> {{ wtf.form_field(edit_form.submit, class="btn btn-primary") }} - <a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=contest.contest_id, site_id=site_id) }}">Zrušit</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_contest_solutions') }}">Zrušit</a> </div> </form> {% else %} <div class='btn-group'> - {% if sc.allow_create_solutions %} - <a class="btn btn-primary" href="{{ url_for('org_contest_solutions_edit', id=contest.contest_id, site_id=site_id) }}">Založit řešení</a> + {% if rights.can_create_solutions() %} + <a class="btn btn-primary" href="{{ ctx.url_for('org_contest_solutions_edit') }}">Založit řešení</a> {% endif %} </div> {% endif %} diff --git a/mo/web/templates/org_contest_task.html b/mo/web/templates/org_contest_task.html index 9e8c65c00adbdfc977971e546976447ac78193b9..96fc3f4c974a7939d619b9713194257aa161bfc2 100644 --- a/mo/web/templates/org_contest_task.html +++ b/mo/web/templates/org_contest_task.html @@ -1,23 +1,20 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% set contest = sc.contest %} -{% set ct_id = contest.contest_id %} -{% set round = sc.round %} -{% set site = sc.site %} -{% set site_id = site.place_id if site else None %} -{% set task = sc.task %} +{% set allow_edit_points=rights.can_edit_points() %} +{% set allow_upload_solutions=rights.can_upload_solutions() %} +{% set allow_upload_feedback=rights.can_upload_feedback() %} -{% block title %}{{ "Zadávání bodů" if points_form else "Založení řešení" if create_form else "Odevzdaná řešení" }} úlohy {{ task.code }} {{ task.name }}{% endblock %} +{% block title %}{{ "Zadávání bodů" if points_form else "Založení řešení" if create_form else "Odevzdaná řešení" }} úlohy {{ ctx.task.code }} {{ ctx.task.name }}{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, site=site, task=task, action="Zadávání bodů" if points_form else "Založení řešení" if create_form else None) }} +{{ ctx.breadcrumbs(action="Zadávání bodů" if points_form else "Založení řešení" if create_form else None) }} {% endblock %} {% block pretitle %} <div class="btn-group pull-right"> - <a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=ct_id, site_id=site_id) }}">Všechny úlohy</a> - {% if contest.state in [RoundState.grading, RoundState.closed] %} - <a class="btn btn-default" href="{{ url_for('org_score', contest_id=ct_id) }}">Výsledky {{ round.get_level().name_genitive() }}</a> - <a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky kola</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_contest_solutions', task_id=None) }}">Všechny úlohy</a> + {% if ctx.contest.state in [RoundState.grading, RoundState.closed] %} + <a class="btn btn-default" href="{{ ctx.url_for('org_score', task_id=None) }}">Výsledky {{ ctx.round.get_level().name_genitive() }}</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_score', ct_id=None, task_id=None) }}">Výsledky kola</a> {% endif %} </div> {% endblock %} @@ -29,26 +26,26 @@ <form class="form" method="POST"> {{ form.csrf_token }} {% endif %} -{% with for_user=None, for_task=task, rows=rows %} +{% with for_user=None, for_task=ctx.task, rows=rows %} {% include "parts/org_solution_table.html" %} {% endwith %} {% if form %} <div class='btn-group'> {{ wtf.form_field(form.submit, class="btn btn-primary" ) }} - <a class="btn btn-default" href="{{ url_for('org_contest_task', contest_id=ct_id, task_id=task.task_id, site_id=site_id) }}">Zrušit</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_contest_task') }}">Zrušit</a> </div> </form> {% else %} <div class='btn-group'> - <a class='btn btn-primary' href="{{ url_for('org_contest_task_download', contest_id=ct_id, site_id=site_id, task_id=task.task_id) }}">Stáhnout řešení</a> - {% if sc.allow_upload_feedback %} - <a class='btn btn-primary' href="{{ url_for('org_contest_task_upload', contest_id=ct_id, site_id=site_id, task_id=task.task_id) }}">Nahrát opravená řešení</a> + <a class='btn btn-primary' href="{{ ctx.url_for('org_generic_batch_download') }}">Stáhnout řešení</a> + {% if allow_upload_feedback %} + <a class='btn btn-primary' href="{{ ctx.url_for('org_generic_batch_upload') }}">Nahrát opravená řešení</a> {% endif %} - {% if sc.allow_create_solutions %} - <a class="btn btn-primary" href="{{ url_for('org_contest_task_create', contest_id=ct_id, task_id=task.task_id, site_id=site_id) }}">Založit řešení</a> + {% if allow_create_solutions %} + <a class="btn btn-primary" href="{{ ctx.url_for('org_contest_task_create') }}">Založit řešení</a> {% endif %} - {% if not site and sc.allow_edit_points %} - <a class="btn btn-primary" href="{{ url_for('org_contest_task_points', contest_id=ct_id, task_id=task.task_id) }}">Zadat body</a> + {% if not ctx.site and allow_edit_points %} + <a class="btn btn-primary" href="{{ ctx.url_for('org_contest_task_points') }}">Zadat body</a> {% endif %} </div> {% endif %} diff --git a/mo/web/templates/org_contest_user.html b/mo/web/templates/org_contest_user.html index d37540fb4adf55594f1b70a185cfd2171da08811..a77e15c32ef175475d481e5728e65f53559d45b3 100644 --- a/mo/web/templates/org_contest_user.html +++ b/mo/web/templates/org_contest_user.html @@ -1,13 +1,13 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% set contest = sc.contest %} +{% set contest = ctx.contest %} {% set ct_id = contest.contest_id %} -{% set round = sc.round %} -{% set user = sc.user %} +{% set round = ctx.round %} +{% set user = ctx.user %} {% block title %}{{ round.round_code() }}: účastník {{ user.full_name() }}{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, user=user) }} +{{ ctx.breadcrumbs() }} {% endblock %} {% block body %} @@ -15,14 +15,14 @@ <h4>Rychlé odkazy</h4> Soutěžní kolo: <div class="btn-group"> - <a class="btn btn-default" href="{{ url_for('org_round_list', id=round.round_id) }}">Účastníci</a> - <a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_generic_list', ct_id=None) }}">Účastníci</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_score', ct_id=None) }}">Výsledky</a> </div> <br>{{ round.get_level().name|capitalize }}: <div class="btn-group"> - <a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=ct_id) }}">Tabulka řešení</a> - <a class="btn btn-default" href="{{ url_for('org_contest_list', id=ct_id) }}">Účastníci</a> - <a class="btn btn-default" href="{{ url_for('org_score', contest_id=ct_id) }}">Výsledky</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_contest_solutions') }}">Tabulka řešení</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_generic_list') }}">Účastníci</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_score') }}">Výsledky</a> </div> </div> @@ -38,14 +38,14 @@ <tr><td>Rok narození:<td>{{ pant.birth_year }} <tr><td>Poznámka:<td style="white-space: pre-line;">{{ user.note }} <thead> - <tr><th colspan='2'>Účast v kole + <tr><th colspan='2'>Účast v kole </thead> - <tr><td>{{ round.get_level().name|capitalize }}:<td><a href='{{ url_for('org_contest', id=ct_id) }}'>{{ contest.place.name }}</a> - <tr><td>Soutěžní místo:<td><a href='{{ url_for('org_contest', id=ct_id, site_id=sc.pion.place_id) }}'>{{ sc.pion.place.name }}</a> - <tr><td>Stav účasti:<td>{{ sc.pion.state.friendly_name() }} + <tr><td>{{ round.get_level().name|capitalize }}:<td><a href='{{ ctx.url_for('org_contest') }}'>{{ contest.place.name }}</a> + <tr><td>Soutěžní místo:<td><a href='{{ ctx.url_for('org_contest', site_id=ctx.pion.place_id) }}'>{{ ctx.pion.place.name }}</a> + <tr><td>Stav účasti:<td>{{ ctx.pion.state.friendly_name() }} </table> -<a class="btn btn-default" href="{{ url_for('org_user', id=user.user_id) }}">Detail uživatele</a> +<a class="btn btn-default" href="{{ user|user_url }}">Detail uživatele</a> {% include "parts/org_submit_warning.html" %} diff --git a/mo/web/templates/org_edit_statement.html b/mo/web/templates/org_edit_statement.html index 3f5a3cf06d560096db40821cc50e27b9ac22cfb6..2c303e6b3238c2e99c8a3508ad470dffa67d2ad6 100644 --- a/mo/web/templates/org_edit_statement.html +++ b/mo/web/templates/org_edit_statement.html @@ -3,7 +3,7 @@ {% block title %}Zadání kola {{ round.round_code() }}{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, action="Zadáni") }} +{{ ctx.breadcrumbs(action="Zadáni") }} {% endblock %} {% block body %} diff --git a/mo/web/templates/org_generic_batch_download.html b/mo/web/templates/org_generic_batch_download.html index b2248bed84cdc41a3a6973c9f2a5c180ceaa577f..d73c788fa9bf4d937355c6c04ce25348fc421a29 100644 --- a/mo/web/templates/org_generic_batch_download.html +++ b/mo/web/templates/org_generic_batch_download.html @@ -3,7 +3,7 @@ {% block title %}Stažení řešení úlohy {{ task.code }} {{ task.name }}{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, site=site, task=task, action="Stažení řešení") }} +{{ ctx.breadcrumbs(action="Stažení řešení") }} {% endblock %} {% block body %} @@ -14,10 +14,14 @@ </table> <p>Zde si můžete stáhnout všechna řešení této úlohy -{% if contest %} - {{ contest.place.name_locative() }}. +{% if ctx.site %} + {{ ctx.site.name_locative() }}. +{% elif contest %} + {{ contest.place.name_locative() }}. +{% elif ctx.hier_place %} + {{ ctx.hier_place.name_locative() }}. {% else %} - ze všech oblastí tohoto kola. + ze všech soutěží tohoto kola. {% endif %} Pozor na to, že jich může být poměrně hodně (viz celková velikost dat výše). diff --git a/mo/web/templates/org_generic_batch_points.html b/mo/web/templates/org_generic_batch_points.html index 074f4265deee32e6383d322e8bfc8b86ae49091b..aaa82898ab73e787598fc4ab529f2faa5a8a0c14 100644 --- a/mo/web/templates/org_generic_batch_points.html +++ b/mo/web/templates/org_generic_batch_points.html @@ -3,7 +3,7 @@ {% block title %}Dávkové bodování úlohy {{ task.code }} {{ task.name }}{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, task=task, action="Dávkové bodování") }} +{{ ctx.breadcrumbs(action="Dávkové bodování") }} {% endblock %} {% block body %} diff --git a/mo/web/templates/org_generic_batch_upload.html b/mo/web/templates/org_generic_batch_upload.html index 99d78527a97bdd4893dcdfa6a4ac71c3bb1498a8..e085c0ae9b36bd13e7f49918284a9abcf778fca3 100644 --- a/mo/web/templates/org_generic_batch_upload.html +++ b/mo/web/templates/org_generic_batch_upload.html @@ -1,10 +1,9 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% set site_id = site.place_id if site else None %} {% block title %}Nahrání opravených řešení úlohy {{ task.code }} {{ task.name }}{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, site=site, task=task, action="Nahrání opravených řešení") }} +{{ ctx.breadcrumbs(action="Nahrání opravených řešení") }} {% endblock %} {% block body %} diff --git a/mo/web/templates/org_generic_import.html b/mo/web/templates/org_generic_import.html index 295a450726147a5b15b08460303250a439a23526..6ed927d607648ab8660340faeb70a481ebc5831b 100644 --- a/mo/web/templates/org_generic_import.html +++ b/mo/web/templates/org_generic_import.html @@ -5,7 +5,7 @@ Import dat do {% if contest %}soutěže {{ contest.place.name_locative() }}{% else %}kola {{ round.round_code() }}{% endif %} {% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, action="Import dat") }} +{{ ctx.breadcrumbs(action="Import dat") }} {% endblock %} {% block body %} @@ -33,7 +33,7 @@ Import dat do {% if contest %}soutěže {{ contest.place.name_locative() }}{% el Detaily fungování importu najdete v <a href='{{ url_for('doc_import') }}'>dokumentaci</a>. {% if not contest %} -<p><em>Pozor, zde se importuje do všech oblastí najednou, takže je nutné uvádět +<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 %} diff --git a/mo/web/templates/org_generic_list.html b/mo/web/templates/org_generic_list.html new file mode 100644 index 0000000000000000000000000000000000000000..702955b979fed8bba9fd7f047c890b417270f688 --- /dev/null +++ b/mo/web/templates/org_generic_list.html @@ -0,0 +1,162 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} + +{% block title %} + {% if contest %} + Seznam účastníků {% if site %}v soutěžním místě {{ site.name }}{% else %}{{ contest.place.name_locative() }}{% endif %} + {% else %} + Seznam účastníků kola {{ round.round_code() }} + {% endif %} +{% endblock %} +{% block breadcrumbs %} +{{ ctx.breadcrumbs(action="Seznam účastníků" if table else "E-maily", table=False if table else True) }} +{% endblock %} +{% set id = contest.contest_id %} +{% set site_id = site.place_id if site else None %} + +{% block body %} +<div class="form-frame"> +<form action="" method="GET" class="form form-inline" role="form"> + <div class="form-row"> + {% if not contest %} + {{ wtf.form_field(filter.contest_place, placeholder='Kód', size=8) }} + {% endif %} + {% if not site %} + {{ wtf.form_field(filter.participation_place, size=8) }} + {% endif %} + {{ wtf.form_field(filter.school, size=8) }} + {{ wtf.form_field(filter.participation_state) }} + </div> + <div class="form-row" style="margin-top: 5px;"> + <div class="btn-group"> + {{ wtf.form_field(filter.submit, class='btn btn-primary') }} + {% if table %} + <button class="btn btn-default" name="format" value="cs_csv" title="Stáhnout celý výsledek v CSV">↓ CSV</button> + <button class="btn btn-default" name="format" value="tsv" title="Stáhnout celý výsledek v TSV">↓ TSV</button> + {% endif %} + </div> + {% if table %} + <div style="float: right"> + Stránka {{ filter.offset.data // filter.limit.data + 1}} z {{ (count / filter.limit.data)|round(0, 'ceil')|int }}: + <div class="btn-group"> + {% if filter.offset.data > 0 %} + {{ wtf.form_field(filter.previous) }} + {% else %} + <button class="btn" disabled>Předchozí</button> + {% endif %} + {% if count > filter.offset.data + filter.limit.data %} + {{ wtf.form_field(filter.next) }} + {% else %} + <button class="btn" disabled>Další</button> + {% endif %} + </div> + <input type="hidden" name="offset" value="{{filter.offset.data}}"> + <input type="hidden" name="limit" value="{{filter.limit.data}}"> + </div> + {% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %} + {% if count > 0 %} + Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených účastníků</b>. + {% else %} + <b>Nebyly nalezeny žádné záznamy účastníků.</b> + {% endif %} + {% else %} + Celkem <b>{{count|inflected('nalezený účastník', 'nalezení účastníci', 'nalezených účastníků')}}</b>. + {% endif %} + </div> +</form> +</div> + +{% if table %} + {% if action_form %} + <form action="" method="POST" class="form form-horizontal" role="form"> + {% endif %} + + {{ table.to_html() }} + + {% if contest %} + <a class="btn btn-primary" href="{{ url_for('org_contest_add_user', ct_id=contest.contest_id, site_id=site.place_id if site else None) }}">Přidat účastníka</a> + {% endif %} + <a class="btn btn-default" + title="Zobrazí emailové adresy ve snadno zkopírovatelném formátu" + href="{{ ctx.url_for('org_generic_list_emails', **request.args) }}"> + Vypsat e-mailové adresy + </a> + + {% if action_form %} + {{ action_form.csrf_token }} + <h3>Provést akci</h3> + <div class="form-frame"> + <div class="form-group"> + <label class="control-label col-sm-2">Provést akci na:</label> + <div class="col-sm-5 radio"> + <label> + <input id="action_on-0" name="action_on" type="radio" value="all" required{% if action_form.action_on.data == 'all' %} checked{% endif %}> + všech vyfiltrovaných účastnících + </label> + </div><div class="col-sm-5 radio"> + <label> + <input id="action_on-1" name="action_on" type="radio" value="checked" required{% if action_form.action_on.data == 'checked' %} checked{% endif %}> + pouze zaškrtnutých účastnících + </label> + </div> + </div> + <div class="form-group"> + <label class="control-label col-sm-2" for="participation_state">Stav účasti</label> + <div class="col-sm-6">{{ wtf.form_field(action_form.participation_state, form_type='inline') }}</div> + <div class="col-sm-4">{{ wtf.form_field(action_form.set_participation_state, form_type='inline', class='btn btn-primary') }}</div> + </div> + <div class="form-group"> + <label class="control-label col-sm-2" for="participation_place">Soutěžní místo</label> + <div class="col-sm-6">{{ wtf.form_field(action_form.participation_place, form_type='inline', placeholder='Kód místa') }}</div> + <div class="col-sm-4">{{ wtf.form_field(action_form.set_participation_place, form_type='inline', class='btn btn-primary') }}</div> + </div> + <div class="form-group"> + <label class="control-label col-sm-2" for="contest_place"> + {{ round.get_level().name|capitalize }} + </label> + <div class="col-sm-6"> + {{ wtf.form_field(action_form.contest_place, form_type='inline', placeholder='Kód místa') }} + <p class="help-block"> + {{ round.get_level().name_locative("V tomto", "V této", "V tomto") }} musí existovat soutěž pro {{ round.name|lower }} kategorie {{ round.category }}. + </p> + </div> + <div class="col-sm-4">{{ wtf.form_field(action_form.set_contest, form_type='inline', class='btn btn-primary', value='Přesunout do ' + round.get_level().name_genitive('jiného', 'jiné', 'jiného')) }}</div> + </div> + <div class="form-group"> + <label class="control-label col-sm-2" for="contest_place">Smazání účasti</label> + <div class="col-sm-6"><p class="help-block">Dojde ke smazání účasti v tomto kole, ne účastníka z ročníku.</p></div> + <div class="col-sm-4">{{ wtf.form_field(action_form.remove_participation, form_type='inline', class='btn btn-danger') }}</div> + </div> + </div> + </form> + {% else %} + <p> + <i>Nemáte právo k editaci účastníků v {{ round.get_level().name_locative("v tomto", "v této", "v tomto") }}.</i> + </p> + {% endif %} +{% else %} + <h3>E-mailové adresy</h3> + + {% if emails %} + <pre>{{ emails|join('\n')|escape }}</pre> + <textarea id="emails-textarea" style="display: none">{{ emails|join('\n')|escape }}</textarea> + + <p> + <a class="btn btn-primary" href="{{ mailto_link }}">Vytvořit e-mail pro {{ count|inflected("adresáta", "adresáty", "adresátů") }}</a> + <button class="btn btn-default" id="copy-emails">Zkopírovat všechny adresy do schránky</button> + <script type="text/javascript"> + var ta = document.getElementById('emails-textarea'); + document.getElementById('copy-emails').addEventListener('click', function () { + ta.style.display = 'block'; + ta.select(); + document.execCommand('copy', false); + ta.style.display = 'none'; + }); + </script> + </p> + + <p>E-mailové adresy účastníků prosím vkládejte do pole pro <b>skrytou kopii (Bcc)</b>, ať si navzájem nevidí své e-maily.</p> + + {% else %}<i>Žádné e-mailové adresy k vypsání.</i>{% endif %} +{% endif %} +{% endblock %} diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html index e4a5b0f5011b8458a24583502c2dadb683287f0e..8436ddc60af66b727be08c1ae0c43073d7c53e0e 100644 --- a/mo/web/templates/org_index.html +++ b/mo/web/templates/org_index.html @@ -2,34 +2,46 @@ {% block title %}Organizátorské rozhraní{% endblock %} {% block body %} -{% if contests %} +{% if overview %} <h3>Moje soutěže</h3> <table class="table table-bordered table-condensed greyhead"> {% set curr = namespace(level=-1) %} - {% for c in contests %} - {% if curr.level != c.round.level %} + {% for o in overview %} + {% if curr.level != o.round.level %} <thead><tr> <th>ID <th>Kategorie <th>Kolo - <th>{{ c.round.get_level().name|capitalize }} + <th>{{ o.round.get_level().name|capitalize }} <th>Stav <th>Moje role <th>Odkazy </thead> - {% set curr.level = c.round.level %} + {% set curr.level = o.round.level %} + {% endif %} + + {% if o.contest %} + {% set detail_url = url_for('org_contest', ct_id=o.contest.contest_id) %} + {% elif o.place.level == 0 %} + {% set detail_url = url_for('org_round', round_id=o.round.round_id) %} + {% else %} + {% set detail_url = url_for('org_round', round_id=o.round.round_id, hier_id=o.place.place_id) %} {% endif %} <tr> - <td><a href='{{ url_for('org_contest', id=c.contest_id) }}'>{{ c.round.round_code() }}</a> - <td class="text-center"><b>{{ c.round.category }}</b> - <td>{{ c.round.name }} - <td>{{ c.place.name }} - <td class="rstate-{{c.state.name}}">{{ c.state.friendly_name() }} - <td>{% for r in contest_roles[c.contest_id] %}{{ role_type_names[r] }}{% if not loop.last %}<br>{% endif %}{% endfor %} + <td><a href='{{ detail_url }}'>{{ o.round.round_code() }}</a> + <td class="text-center"><b>{{ o.round.category }}</b> + <td>{{ o.round.name }} + {% if o.contest %} + <td>{{ o.place.name }} + <td class="rstate-{{o.contest.state.name}}">{{ o.contest.state.friendly_name() }} + {% else %} + <td><i>{{ o.place.name_locative() }}</i> <td> - <a class="btn btn-xs btn-primary" href='{{ url_for('org_contest', id=c.contest_id) }}'>Detail</a> + {% endif %} + <td>{% for r in o.role_list %}{{ role_type_names[r] }}{% if not loop.last %}<br>{% endif %}{% endfor %} + <td><a class="btn btn-xs btn-primary" href='{{ detail_url }}'>Detail</a> {% endfor %} </table> diff --git a/mo/web/templates/org_place.html b/mo/web/templates/org_place.html index 94f8df8e77ba0934a3043812c5f5f33ef4d133c2..0baf84755988aa855b3253980d56623bfbfe4afb 100644 --- a/mo/web/templates/org_place.html +++ b/mo/web/templates/org_place.html @@ -72,7 +72,7 @@ {% for c in contests %} <tr> {% set r = c.round %} - <td><a href='{{ url_for('org_contest', id=c.contest_id) }}'>{{ r.round_code() }}</a> + <td><a href='{{ url_for('org_contest', ct_id=c.contest_id) }}'>{{ r.round_code() }}</a> <td>{{ r.year }} <td>{{ r.category }} <td>{{ r.name }} diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html index bd91f5a844a660e1917ff0ee02e7c3be2ccdf16e..ddb74769aa51ad4f7a30c0c211cac1615d08454b 100644 --- a/mo/web/templates/org_round.html +++ b/mo/web/templates/org_round.html @@ -1,16 +1,30 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} +{% set in_hier = ctx.hier_id != None %} +{% set can_manage_round = rights.have_right(Right.manage_round) and not in_hier %} +{% set can_manage_contest = rights.have_right(Right.manage_contest) %} +{% set can_view_contestants = rights.have_right(Right.view_contestants) %} +{% set can_handle_submits = rights.have_right(Right.view_submits) %} +{% set can_upload = rights.can_upload_feedback() %} +{% set can_view_statement = rights.can_view_statement() %} +{% set can_add_contest = g.gatekeeper.rights_generic().have_right(Right.add_contest) %} -{% block title %}{{ round.name }} {{ round.round_code() }}{% endblock %} +{% block title %} + {% if in_hier %} + {{ round.round_code() }}: {{ ctx.hier_place.name }} + {% else %} + {{ round.name }} {{ round.round_code() }} + {% endif %} +{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round) }} +{{ ctx.breadcrumbs() }} {% endblock %} {% block body %} <table class=data style="float: left; margin-right: 10px;"> <thead> - <tr><th colspan=2>Parametry kola <i>(nelze editovat)</i> + <tr><th colspan=2>Parametry kola{% if can_manage_round %} <i>(nelze editovat)</i>{% endif %} </thead> <tr><td>Ročník<td>{{ round.year }} <tr><td>Kategorie<td>{{ round.category }} @@ -22,7 +36,7 @@ <tr><td>Skupina kol:<td> {% for r in group_rounds %} {% if r == round %}<i>{{ r.name }} {{ r.round_code() }} (toto kolo)</i><br> - {% else %}<a href="{{ url_for('org_round', id=r.round_id) }}">{{ r.name }} {{ r.round_code() }}</a><br> + {% else %}<a href="{{ url_for('org_round', round_id=r.round_id) }}">{{ r.name }} {{ r.round_code() }}</a><br> {% endif %} {% endfor %} {% endif %} @@ -50,7 +64,7 @@ {% if not statement_exists %} <span class=error>soubor neexistuje</span> {% elif can_view_statement %} - <a href='{{ url_for('org_task_statement', id=round.round_id) }}'>stáhnout</a> + <a href='{{ ctx.url_for('org_task_statement') }}'>stáhnout</a> {% else %} není dostupné {% endif %} @@ -59,7 +73,7 @@ {% endif %} <thead> <tr><th colspan=2>Nastavení výsledkové listiny{% if round.is_subround() %} - <i>(přejato z <a href="{{ url_for('org_round', id=round.master.round_id) }}">{{ round.master.round_code() }}</a>)</i> + <i>(přejato z <a href="{{ url_for('org_round', round_id=round.master.round_id) }}">{{ round.master.round_code() }}</a>)</i> {% endif %} </thead> <tr><td>Výsledková listina<td>{{ round.master.score_mode.friendly_name() }} @@ -69,52 +83,77 @@ </table> <div style="clear: both;"></div> +{% if can_view_contestants or can_manage_contest or can_manage_round or round.has_messages %} <div class="btn-group"> - <a class="btn btn-primary" href='{{ url_for('org_round_list', id=round.round_id) }}'>Seznam účastníků</a> - {% if round.state in [RoundState.grading, RoundState.closed, RoundState.delegate] %} - <a class="btn btn-primary" href='{{ url_for('org_score', round_id=round.round_id) }}'>Výsledky</a> + {% if can_view_contestants %} + <a class="btn btn-primary" href='{{ ctx.url_for('org_generic_list') }}'>Seznam účastníků</a> {% endif %} - {% if can_manage_contestants %} - <a class="btn btn-default" href='{{ url_for('org_round_import', id=round.round_id) }}'>Importovat data</a> + {% if can_view_contestants and round.state in [RoundState.grading, RoundState.closed, RoundState.delegate] and not in_hier %} + <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> {% endif %} {% if can_manage_round %} - <a class="btn btn-default" href='{{ url_for('org_round_edit', id=round.round_id) }}'>Nastavení a termíny</a> - <a class="btn btn-default" href='{{ url_for('org_edit_statement', id=round.round_id) }}'>Zadání</a> + <a class="btn btn-default" href='{{ ctx.url_for('org_round_edit') }}'>Nastavení a termíny</a> + <a class="btn btn-default" href='{{ ctx.url_for('org_edit_statement') }}'>Zadání</a> {% endif %} {% if round.has_messages %} - <a class="btn btn-default" href='{{ url_for('org_round_messages', id=round.round_id) }}'>Zprávičky</a> + <a class="btn btn-default" href='{{ ctx.url_for('org_round_messages') }}'>Zprávičky</a> {% endif %} - {% if g.user.is_admin %} + {% if g.user.is_admin and not in_hier %} <a class="btn btn-default" href='{{ log_url('round', round.round_id) }}'>Historie</a> {% endif %} </div> +{% endif %} <h3>Soutěže</h3> -{% if contests_counts %} -<table class=data> - <thead> - <tr> - <th>{{ round.get_level().name|capitalize }} - <th>Stav - <th>Počet účastníků - </tr> - </thead> - {% for (c, count) in contests_counts %} - <tr> - <td><a href='{{ url_for('org_contest', id=c.contest_id) }}'>{{ c.place.name }}</a> - {% with state=c.state %} - <td class='rstate-{{state.name}}'>{{ state.friendly_name() }} - {% endwith %} - <td>{{ count }} - {% endfor %} - <tfoot> +{% if reg_total.num_contests %} + {% set show_contests = reg_stats[0].contest != None %} + <table class=data> + <thead> + <tr> + {% if show_contests %} + <th>{{ round.get_level().name|capitalize }} + <th>Stav + {% else %} + <th>{{ reg_stats[0].region.type_name()|capitalize }} + <th>Počet soutěží + <th>Stavy soutěží + {% endif %} + <th>Počet účastníků + <th>Počet přihlášek + </tr> + </thead> + {% for rs in reg_stats %} <tr> - <th>Celkem - <th> - <th>{{ contests_counts|sum(attribute=1) }} - </tr> - </tfoot> -</table> + {% if show_contests %} + <td><a href='{{ url_for('org_contest', ct_id=rs.contest.contest_id) }}'>{{ rs.region.name }}</a> + {% with state=rs.contest.state %} + <td class='rstate-{{state.name}}'>{{ state.friendly_name() }} + {% endwith %} + {% else %} + <td><a href='{{ ctx.url_for('org_round', hier_id=rs.region.place_id) }}'>{{ rs.region.name }}</a> + <td>{{ rs.num_contests }} + <td>{% for s in rs.contest_states %}<span class='rstate-{{s.name}}'>{{ s.friendly_name() }}</span> {% endfor %} + {% endif %} + <td>{{ rs.num_active_pants }} + <td>{{ rs.num_unconfirmed_pants }} + {% endfor %} + <tfoot> + <tr> + <th>Celkem + {% if show_contests %} + <th> + {% else %} + <th>{{ reg_total.num_contests }} + <th> + {% endif %} + <th>{{ reg_total.num_active_pants }} + <th>{{ reg_total.num_unconfirmed_pants }} + </tr> + </tfoot> + </table> {% else %} <p>Zatím nebyly založeny žádné soutěže. {% endif %} @@ -128,7 +167,7 @@ {% endif %} <h3>Úlohy</h3> -{% if tasks %} +{% if task_info %} <table class=data> <thead> <tr> @@ -140,15 +179,15 @@ {% if can_handle_submits or can_upload %}<th>Dávkové operace{% endif %} </tr> </thead> - {% for task in tasks %} + {% for task, sol_count in task_info %} <tr> <td>{{ task.code }} <td>{{ task.name }} - <td>{{ task.sol_count }} + <td>{{ sol_count }} <td>{{ task.max_points|decimal|none_value('–') }} {% if can_manage_round %} <td><div class="btn-group"> - <a class="btn btn-xs btn-primary" href="{{ url_for('org_round_task_edit', id=round.round_id, task_id=task.task_id) }}">Editovat</a> + <a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_round_task_edit', task_id=task.task_id) }}">Editovat</a> {% if task.sol_count == 0 %} <form action="" method="POST" onsubmit="return confirm('Opravdu nenávratně smazat?')" class="btn-group"> {{ form_delete_task.csrf_token() }} @@ -164,13 +203,13 @@ {% if can_handle_submits or can_upload %} <td><div class="btn-group"> {% if can_handle_submits %} - <a class="btn btn-xs btn-primary" href="{{ url_for('org_round_task_download', round_id=round.round_id, task_id=task.task_id) }}">Stáhnout ZIP</a> + <a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_generic_batch_download', task_id=task.task_id) }}">Stáhnout ZIP</a> {% endif %} {% if can_upload %} - <a class="btn btn-xs btn-primary" href="{{ url_for('org_round_task_upload', round_id=round.round_id, task_id=task.task_id) }}">Nahrát ZIP</a> + <a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_generic_batch_upload', task_id=task.task_id) }}">Nahrát ZIP</a> {% endif %} {% if can_upload %} - <a class="btn btn-xs btn-default" href="{{ url_for('org_round_task_batch_points', round_id=round.round_id, task_id=task.task_id) }}">Nahrát body</a> + <a class="btn btn-xs btn-default" href="{{ ctx.url_for('org_generic_batch_points', task_id=task.task_id) }}">Nahrát body</a> {% endif %} </div> {% endif %} @@ -181,7 +220,7 @@ <p>Zatím nebyly přidány žádné úlohy.</p> {% endif %} {% if can_manage_round %} -<a class="btn btn-primary right-float" href="{{ url_for('org_round_task_new', id=round.round_id) }}">Nová úloha</a> +<a class="btn btn-primary right-float" href="{{ ctx.url_for('org_round_task_new') }}">Nová úloha</a> {% endif %} {% endblock %} diff --git a/mo/web/templates/org_round_edit.html b/mo/web/templates/org_round_edit.html index 8e9ea08798933dd26db84422c3460f7743f338ff..0e42ebd87aba8af08e40e202dfaccb731dba5d33 100644 --- a/mo/web/templates/org_round_edit.html +++ b/mo/web/templates/org_round_edit.html @@ -3,7 +3,7 @@ {% block title %}Editace kola {{ round.round_code() }}{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, action="Editace") }} +{{ ctx.breadcrumbs(action="Editace") }} {% endblock %} {% block body %} diff --git a/mo/web/templates/org_round_list.html b/mo/web/templates/org_round_list.html deleted file mode 100644 index 4dbdcf32b3daec2c0ded2ae58d7a419bae2b4da3..0000000000000000000000000000000000000000 --- a/mo/web/templates/org_round_list.html +++ /dev/null @@ -1,67 +0,0 @@ -{% extends "base.html" %} -{% import "bootstrap/wtf.html" as wtf %} - -{% block title %}Seznam účastníků kola {{ round.round_code() }}{% endblock %} -{% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, action="Seznam účastníků" if table else "E-maily", table=False if table else True) }} -{% endblock %} -{% set id = round.round_id %} - -{% block body %} -<div class="form-frame"> -<form action="" method="GET" class="form form-inline" role="form"> - <div class="form-row"> - {{ wtf.form_field(filter.contest_place, size=8) }} - {{ wtf.form_field(filter.participation_place, size=8) }} - {{ wtf.form_field(filter.school, size=8) }} - {{ wtf.form_field(filter.participation_state) }} - </div> - <div class="form-row" style="margin-top: 5px;"> - <div class="btn-group"> - {{ wtf.form_field(filter.submit, class='btn btn-primary') }} - {% if table %} - <button class="btn btn-default" name="format" value="cs_csv" title="Stáhnout celý výsledek v CSV">↓ CSV</button> - <button class="btn btn-default" name="format" value="tsv" title="Stáhnout celý výsledek v TSV">↓ TSV</button> - {% endif %} - </div> - {% if table %} - <div style="float: right"> - Stránka {{ filter.offset.data // filter.limit.data + 1}} z {{ (count / filter.limit.data)|round(0, 'ceil')|int }}: - <div class="btn-group"> - {% if filter.offset.data > 0 %} - {{ wtf.form_field(filter.previous) }} - {% else %} - <button class="btn" disabled>Předchozí</button> - {% endif %} - {% if count > filter.offset.data + filter.limit.data %} - {{ wtf.form_field(filter.next) }} - {% else %} - <button class="btn" disabled>Další</button> - {% endif %} - </div> - <input type="hidden" name="offset" value="{{filter.offset.data}}"> - <input type="hidden" name="limit" value="{{filter.limit.data}}"> - </div> - {% set max = filter.offset.data + filter.limit.data if filter.offset.data + filter.limit.data < count else count %} - {% if count > 0 %} - Zobrazuji záznamy <b>{{filter.offset.data + 1}}</b> až <b>{{ max }}</b> z <b>{{count}} nalezených účastníků</b>. - {% else %} - <b>Nebyly nalezeny žádné záznamy účastníků.</b> - {% endif %} - {% else %} - Celkem <b>{{count|inflected('nalezený účastník', 'nalezení účastníci', 'nalezených účastníků')}}</b>. - {% endif %} - </div> -</form> -</div> - -{% if table %} - {% include 'parts/org_participants_table_actions.html' %} - {% if form_actions %} - <br> - <i>Upozornění: Můžete editovat jen účastníky soutěžící v oblastech, ke kterým máte právo.</i> - {% endif %} -{% else %} - {% include 'parts/org_participants_emails.html' %} -{% endif %} -{% endblock %} diff --git a/mo/web/templates/org_round_messages.html b/mo/web/templates/org_round_messages.html index 1be2a7367b9db1ef420ee6ad65f7a19d925270f4..d5e9ec3a7a648cdcd9878dfd7b3d80787c962a17 100644 --- a/mo/web/templates/org_round_messages.html +++ b/mo/web/templates/org_round_messages.html @@ -3,7 +3,7 @@ {% block title %}{{ round.name }} {{ round.round_code() }} – zprávičky{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, action='Zprávičky') }} +{{ ctx.breadcrumbs(action='Zprávičky') }} {% endblock %} {% block body %} diff --git a/mo/web/templates/org_round_task_edit.html b/mo/web/templates/org_round_task_edit.html index fa26bbeacbf9a3f3df0c50101e627903b64c33b5..a43fbdb88b969bedf6e7160a19391dc76666b396 100644 --- a/mo/web/templates/org_round_task_edit.html +++ b/mo/web/templates/org_round_task_edit.html @@ -5,7 +5,7 @@ {% if task %}Editace úlohy {{ task.code }} {{ task.name }}{% else %}Nová úloha{% endif %} {% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, task=task, action="Nová úloha" if not task else None) }} +{{ ctx.breadcrumbs(action="Nová úloha" if not ctx.task else None) }} {% endblock %} {% block body %} diff --git a/mo/web/templates/org_rounds.html b/mo/web/templates/org_rounds.html index d4b415dc7e863a0df32fd04f885522194ae67780..b37c89c4c10c8245bfd9f6204f5f2b308ce80657 100644 --- a/mo/web/templates/org_rounds.html +++ b/mo/web/templates/org_rounds.html @@ -14,7 +14,7 @@ </thead> {% for r in rounds %} <tr> - <td><a href='{{ url_for('org_round', id=r.round_id) }}'>{{ r.round_code() }}</a> + <td><a href='{{ url_for('org_round', round_id=r.round_id) }}'>{{ r.round_code() }}</a> <td>{{ r.year }} <td>{{ r.category }} <td>{{ r.seq }}{{ r.part_code() }} diff --git a/mo/web/templates/org_score.html b/mo/web/templates/org_score.html index 8e0b6e98dc2997afa4f9b8c26d2e3e0d98ebe1df..0513cf4d27055f070c286c67d7a94396ca72a7ff 100644 --- a/mo/web/templates/org_score.html +++ b/mo/web/templates/org_score.html @@ -4,14 +4,14 @@ {{ round.round_code() }}: Výsledky pro {{ round.name|lower }} kategorie {{ round.category }}{% if contest %} {{ contest.place.name_locative() }}{% endif %} {% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=round, contest=contest, action="Výsledky oblasti" if contest else "Výsledky kola") }} +{{ ctx.breadcrumbs(action="Výsledky oblasti" if contest else "Výsledky kola") }} {% endblock %} {% block pretitle %} <div class="btn-group pull-right"> {% if contest %} - <a class="btn btn-default" href="{{ url_for('org_contest_solutions', id=contest.contest_id) }}">Odevzdaná řešení</a> - <a class="btn btn-default" href="{{ url_for('org_score', round_id=round.round_id) }}">Výsledky kola</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_contest_solutions') }}">Odevzdaná řešení</a> + <a class="btn btn-default" href="{{ ctx.url_for('org_score', ct_id=None) }}">Výsledky kola</a> {% endif %} </div> {% endblock %} @@ -42,7 +42,7 @@ {% if group_rounds|length > 1 %} <p>Toto je <b>sdílená výsledková listina</b> pro několik kol: -{% for r in group_rounds %}{% if loop.index > 1 %}, {% endif %}<a href="{{ url_for('org_round', id=r.round_id) }}">{{ r.round_code() }} {{ r.name }}</a>{% endfor %}. +{% for r in group_rounds %}{% if loop.index > 1 %}, {% endif %}<a href="{{ url_for('org_round', round_id=r.round_id) }}">{{ r.round_code() }} {{ r.name }}</a>{% endfor %}. Jsou v ní započítány body ze všech úloh těchto kol.</p> {% endif %} diff --git a/mo/web/templates/org_submit_list.html b/mo/web/templates/org_submit_list.html index ff445d3ee105f6eeae8c395321ade119702729ed..09a62ca21fea47eea2cc23b480251453d4c50195 100644 --- a/mo/web/templates/org_submit_list.html +++ b/mo/web/templates/org_submit_list.html @@ -1,20 +1,23 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}{{ sc.user.full_name() }} – řešení úlohy {{ sc.task.code }} {{ sc.task.name }}{% endblock %} +{% set allow_edit_points=rights.can_edit_points() %} +{% set allow_upload_solutions=rights.can_upload_solutions() %} +{% set allow_upload_feedback=rights.can_upload_feedback() %} + +{% block title %}{{ ctx.user.full_name() }} – řešení úlohy {{ ctx.task.code }} {{ ctx.task.name }}{% endblock %} {% block breadcrumbs %} -{{ contest_breadcrumbs(round=sc.round, contest=sc.contest, site=sc.site, task=sc.task, user=sc.user, action="Detail řešení") }} +{{ ctx.breadcrumbs(action="Detail řešení") }} {% endblock %} {% block body %} -{% set site_id = sc.site.place_id if sc.site else None %} <table class="data"> - <tr><th>Účastník<td>{{ sc.user|pion_link(sc.contest.contest_id) }}{{ sc.user|user_flags }} - <tr><th>Úloha<td><a href='{{ url_for('org_contest_task', contest_id=sc.contest.contest_id, site_id=site_id, task_id=sc.task.task_id) }}'>{{ sc.task.code }} {{ sc.task.name }}</a> + <tr><th>Účastník<td>{{ ctx.user|pion_link(ctx.contest.contest_id) }}{{ ctx.user|user_flags }} + <tr><th>Úloha<td><a href='{{ ctx.url_for('org_contest_task') }}'>{{ ctx.task.code }} {{ ctx.task.name }}</a> {% if solution %} <tr><th>Body<td> {{ solution.points|decimal|none_value(Markup('<span class="unknown">?</span>')) }} - {% if sc.task.max_points is not none %}<span class="hint"> / {{ sc.task.max_points|decimal }}</span>{% endif %} + {% if ctx.task.max_points is not none %}<span class="hint"> / {{ ctx.task.max_points|decimal }}</span>{% endif %} <tr title="Viditelná účastníkovi po uzavření kola"> <th>Poznámka k řešení:<td style="white-space: pre-line;">{{ solution.note|or_dash }}</td> <tr title="Viditelná jen organizátorům"> @@ -63,12 +66,12 @@ Existuje více než jedna verze řešení, finální je podbarvená. {% set active_sol_id = None %} {% endif %} {% for p in sol_papers %} - {% set late = p.check_deadline(sc.round) %} + {% set late = p.check_deadline(ctx.round) %} <tr{% if p.paper_id == active_sol_id %} class='sol-active'{% endif %}> <td{% if late %} class='sol-warn'{% endif %}>{{ p.uploaded_at|timeformat }} <td>{% if p.is_broken() %}nekorektní PDF{% else %}{{ p.pages|or_dash }}{% endif %} <td>{{ p.bytes|or_dash }} - <td>{% if p.uploaded_by_obj == sc.user %}<i>účastník</i>{% else %}{{ p.uploaded_by_obj|user_link }}{% endif %} + <td>{% if p.uploaded_by_obj == ctx.user %}<i>účastník</i>{% else %}{{ p.uploaded_by_obj|user_link }}{% endif %} <td>{% if late %}<span class='sol-warn'>({{ late }})</span> {% endif %}{{ p.note }} {% if p.is_fixed() %}Automaticky opravené nekorektní PDF.{% endif %} <td><div class="btn-group"> @@ -165,21 +168,21 @@ Existuje více než jedna verze oprav, finální je podbarvená. </div> {% else %} -<p>Žádné odevzdané řešení. {% if form and sc.allow_edit_points %}Můžete ho založit pomocí formuláře níže.{% endif %} +<p>Žádné odevzdané řešení. {% if form and allow_edit_points %}Můžete ho založit pomocí formuláře níže.{% endif %} {% endif %} -{% if form and (sc.allow_edit_points or sc.allow_upload_feedback or sc.allow_upload_solutions) %} +{% if form and (allow_edit_points or allow_upload_feedback or allow_upload_solutions) %} <form method="post" class="form-horizontal" enctype="multipart/form-data"> <div class="form-frame"> {{ form.csrf_token }} {% set action = 'Uložit' if solution else 'Založit řešení' %} - {% if sc.allow_edit_points %} + {% if allow_edit_points %} {% if solution %} <h3 style="margin-top: 10px;">Hodnocení řešení</h3> {% else %} <h3>Založit řešení</h3> <p><i>Můžete rovnou vyplnit i poznámky a přidělené body - {%- if sc.allow_upload_feedback or sc.allow_upload_solutions %}, případně rovnou nahrát i soubor řešení nebo opravy{% endif %}. + {%- if allow_upload_feedback or allow_upload_solutions %}, případně rovnou nahrát i soubor řešení nebo opravy{% endif %}. </i></p> {% endif %} {{ wtf.form_field(form.note, form_type='horizontal', horizontal_columns=('sm', 2, 10), rows=4)}} @@ -187,13 +190,13 @@ Existuje více než jedna verze oprav, finální je podbarvená. {{ wtf.form_field(form.points, form_type='horizontal', horizontal_columns=('sm', 2, 10) )}} {{ wtf.form_field( form.submit, form_type='horizontal', class='btn btn-primary', horizontal_columns=('sm', 2, 10), - value=action + (' bez nahrání souboru' if sc.allow_upload_feedback or sc.allow_upload_solutions else '') + value=action + (' bez nahrání souboru' if allow_upload_feedback or allow_upload_solutions else '') )}} {% endif %} - {% if sc.allow_upload_feedback or sc.allow_upload_solutions %} + {% if allow_upload_feedback or allow_upload_solutions %} {% if solution %} <h3>Nahrání souboru</h3> - {% if sc.allow_edit_points %} + {% if allow_edit_points %} <p><i>Lze najednou editovat řešení (například zadat body) i nahrát soubor, použijte tlačítka na spodku formuláře.</i></p> {% endif %} {% else %} @@ -203,10 +206,10 @@ Existuje více než jedna verze oprav, finální je podbarvená. {{ wtf.form_field(form.file_note, form_type='horizontal', horizontal_columns=('sm', 2, 10)) }} <div class="form-group"> <div class="btn btn-group col-sm-offset-2"> - {% if sc.allow_upload_solutions %} + {% if allow_upload_solutions %} {{ wtf.form_field(form.submit_sol, class='btn btn-primary', value=action + ' a nahrát soubor jako řešení' )}} {% endif %} - {% if sc.allow_upload_feedback %} + {% if allow_upload_feedback %} {{ wtf.form_field(form.submit_fb, class='btn btn-success', value=action + ' a nahrát soubor jako opravu' )}} {% endif %} </div> @@ -214,7 +217,7 @@ Existuje více než jedna verze oprav, finální je podbarvená. {% endif %} </div> -{% if solution and not solution.final_submit and not solution.final_feedback and sc.allow_create_solutions %} +{% if solution and not solution.final_submit and not solution.final_feedback and allow_create_solutions %} <div class="form-frame"> <h3 style="margin-top: 10px;">Smazání řešení</h3> <p>Toto řešení zatím neobsahuje žádný soubor. Pokud bylo přidáno omylem, můžete ho smazat.</p> diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html index 05463d5fedf7d748cda5aef70109032cae7decbb..9595d8566dcee426aad7afa1425c7ab4ee103145 100644 --- a/mo/web/templates/org_user.html +++ b/mo/web/templates/org_user.html @@ -79,9 +79,9 @@ {% endif %} <td>{{ pion.state.friendly_name() }} <td><div class="btn-group"> - <a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_user', contest_id=contest.contest_id, user_id=user.user_id) }}">Odevzdané úlohy</a> - <a class="btn btn-xs btn-default" href="{{ url_for('org_contest', id=contest.contest_id) }}">Stránka soutěže</a> - <a class="btn btn-xs btn-default" href="{{ url_for('org_round', id=round.round_id) }}">Stránka kola</a> + <a class="btn btn-xs btn-primary" href="{{ url_for('org_contest_user', ct_id=contest.contest_id, user_id=user.user_id) }}">Odevzdané úlohy</a> + <a class="btn btn-xs btn-default" href="{{ url_for('org_contest', ct_id=contest.contest_id) }}">Stránka soutěže</a> + <a class="btn btn-xs btn-default" href="{{ url_for('org_round', round_id=round.round_id) }}">Stránka kola</a> {% if g.user.is_admin %} <a class="btn btn-xs btn-default" href="{{ log_url('participant', user.user_id) }}">Historie</a> {% endif %} diff --git a/mo/web/templates/parts/org_participants_emails.html b/mo/web/templates/parts/org_participants_emails.html deleted file mode 100644 index b84c2102ec95312adcf1c168346399c3e71bb3a1..0000000000000000000000000000000000000000 --- a/mo/web/templates/parts/org_participants_emails.html +++ /dev/null @@ -1,23 +0,0 @@ -<h3>E-mailové adresy</h3> - -{% if emails %} -<pre>{{ emails|join('\n')|escape }}</pre> -<textarea id="emails-textarea" style="display: none">{{ emails|join('\n')|escape }}</textarea> - -<p> -<a class="btn btn-primary" href="{{ mailto_link }}">Vytvořit e-mail pro {{ count|inflected("adresáta", "adresáty", "adresátů") }}</a> -<button class="btn btn-default" id="copy-emails">Zkopírovat všechny adresy do schránky</button> -<script type="text/javascript"> - var ta = document.getElementById('emails-textarea'); - document.getElementById('copy-emails').addEventListener('click', function () { - ta.style.display = 'block'; - ta.select(); - document.execCommand('copy', false); - ta.style.display = 'none'; - }); -</script> -</p> - -<p>E-mailové adresy účastníků prosím vkládejte do pole pro <b>skrytou kopii (Bcc)</b>, ať si navzájem nevidí své e-maily.</p> - -{% else %}<i>Žádné e-mailové adresy k vypsání.</i>{% endif %} diff --git a/mo/web/templates/parts/org_participants_table_actions.html b/mo/web/templates/parts/org_participants_table_actions.html deleted file mode 100644 index 0eec0d02a253fa76728b4d2623192de5c44acd3e..0000000000000000000000000000000000000000 --- a/mo/web/templates/parts/org_participants_table_actions.html +++ /dev/null @@ -1,67 +0,0 @@ -{% if action_form %} -<form action="" method="POST" class="form form-horizontal" role="form"> -{% endif %} - - {{ table.to_html() }} - - {% if contest %} - <a class="btn btn-primary" href="{{ url_for('org_contest_add_user', id=contest.contest_id, site_id=site.place_id if site else None) }}">Přidat účastníka</a> - {% endif %} - <a class="btn btn-default" - title="Zobrazí emailové adresy ve snadno zkopírovatelném formátu" - href="{{ url_for('org_contest_list_emails', id=id, site_id=site_id, **request.args) if contest else url_for('org_round_list_emails', id=id, **request.args) }}"> - Vypsat e-mailové adresy - </a> - -{% if action_form %} - {{ action_form.csrf_token }} - <h3>Provést akci</h3> - <div class="form-frame"> - <div class="form-group"> - <label class="control-label col-sm-2">Provést akci na:</label> - <div class="col-sm-5 radio"> - <label> - <input id="action_on-0" name="action_on" type="radio" value="all" required{% if action_form.action_on.data == 'all' %} checked{% endif %}> - všech vyfiltrovaných účastnících - </label> - </div><div class="col-sm-5 radio"> - <label> - <input id="action_on-1" name="action_on" type="radio" value="checked" required{% if action_form.action_on.data == 'checked' %} checked{% endif %}> - pouze zaškrtnutých účastnících - </label> - </div> - </div> - <div class="form-group"> - <label class="control-label col-sm-2" for="participation_state">Stav účasti</label> - <div class="col-sm-6">{{ wtf.form_field(action_form.participation_state, form_type='inline') }}</div> - <div class="col-sm-4">{{ wtf.form_field(action_form.set_participation_state, form_type='inline', class='btn btn-primary') }}</div> - </div> - <div class="form-group"> - <label class="control-label col-sm-2" for="participation_place">Soutěžní místo</label> - <div class="col-sm-6">{{ wtf.form_field(action_form.participation_place, form_type='inline', placeholder='Kód místa') }}</div> - <div class="col-sm-4">{{ wtf.form_field(action_form.set_participation_place, form_type='inline', class='btn btn-primary') }}</div> - </div> - <div class="form-group"> - <label class="control-label col-sm-2" for="contest_place"> - {{ round.get_level().name|capitalize }} - </label> - <div class="col-sm-6"> - {{ wtf.form_field(action_form.contest_place, form_type='inline', placeholder='Kód místa') }} - <p class="help-block"> - {{ round.get_level().name_locative("V tomto", "V této", "V tomto") }} musí existovat soutěž pro {{ round.name|lower }} kategorie {{ round.category }}. - </p> - </div> - <div class="col-sm-4">{{ wtf.form_field(action_form.set_contest, form_type='inline', class='btn btn-primary', value='Přesunout do ' + round.get_level().name_genitive('jiného', 'jiné', 'jiného')) }}</div> - </div> - <div class="form-group"> - <label class="control-label col-sm-2" for="contest_place">Smazání účasti</label> - <div class="col-sm-6"><p class="help-block">Dojde ke smazání účasti v tomto kole, ne účastníka z ročníku.</p></div> - <div class="col-sm-4">{{ wtf.form_field(action_form.remove_participation, form_type='inline', class='btn btn-danger') }}</div> - </div> - </div> -</form> -{% else %} -<p> -<i>Nemáte právo k editaci účastníků v {{ round.get_level().name_locative("v tomto", "v této", "v tomto") }}.</i> -</p> -{% endif %} diff --git a/mo/web/templates/parts/org_solution_table.html b/mo/web/templates/parts/org_solution_table.html index c8a85be235225cd49e2808b69129e8d1d39e77c4..90ac16ebdd9af009eccb4b4c0d70a784d6012f00 100644 --- a/mo/web/templates/parts/org_solution_table.html +++ b/mo/web/templates/parts/org_solution_table.html @@ -5,9 +5,9 @@ To se hodí, pokud se nechystáte do systému nahrávat soubory řešení, ale j bylo možné vyplnit body. Pokud nějaké řešení založíte omylem, lze toto prázdné řešení smazat v jeho detailu. {% else %} Historii všech odevzdání, oprav a bodů pro každé řešení naleznete v jeho detailu. -{% if sc.allow_upload_feedback or sc.allow_edit_points %}Tamtéž můžete odevzdávat nové verze a změnit, které řešení/oprava je -finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solutions %}Tamtéž můžete odevzdat nové řešení.{% endif %} -{% if for_task and sc.allow_create_solutions %} Hromadně založit řešení pro více řešitelů můžete pomocí tlačítek pod tabulkou.{% endif %} +{% if rights.can_upload_feedback() or ctx.can_edit_points() %}Tamtéž můžete odevzdávat nové verze a změnit, které řešení/oprava je +finální (ve výchozím stavu poslední nahrané).{% elif rights.can_upload_solutions() %}Tamtéž můžete odevzdat nové řešení.{% endif %} +{% if for_task and rights.can_create_solutions() %} Hromadně založit řešení pro více řešitelů můžete pomocí tlačítek pod tabulkou.{% endif %} {% endif %} </i></p> @@ -24,8 +24,8 @@ finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solut <th>Finální oprava <th>Poznámky <th>Přidělené body - {% if not for_user and not site and sc.allow_edit_points and not points_form %} - <a title="Editovat body" href="{{ url_for('org_contest_task_points', contest_id=contest.contest_id, task_id=task.task_id) }}" class="icon pull-right">✎</a> + {% if not for_user and not site and rights.can_edit_points() and not points_form %} + <a title="Editovat body" href="{{ ctx.url_for('org_contest_task_points') }}" class="icon pull-right">✎</a> {% endif %} <th>Akce </tr> @@ -40,7 +40,7 @@ finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solut <tr> {% endif %} <td>{% if for_user %} - <a href='{{ url_for('org_contest_task', contest_id=ct_id, task_id=task.task_id) }}'>{{ task.code }} {{ task.name }}</a> + <a href='{{ ctx.url_for('org_contest_task', task_id=task.task_id) }}'>{{ task.code }} {{ task.name }}</a> {% else %} {{ u|pion_link(contest.contest_id) }}{{ u|user_flags }}</a> {% endif %} @@ -105,7 +105,7 @@ finální (ve výchozím stavu poslední nahrané).{% elif sc.allow_upload_solut {% else %}–{% endif %} {% endif %} <td><div class="btn-group"> - <a class="btn btn-xs btn-primary" href="{{ url_for('org_submit_list', contest_id=ct_id, user_id=u.user_id, task_id=task.task_id, site_id=site_id) }}">Detail</a> + <a class="btn btn-xs btn-primary" href="{{ ctx.url_for('org_submit_list', user_id=u.user_id, task_id=task.task_id, site_id=ctx.site_id) }}">Detail</a> </div> </tr> {% endfor %} diff --git a/mo/web/templates/parts/org_submit_warning.html b/mo/web/templates/parts/org_submit_warning.html index a405c308c06102e832a7fd710f446db35f7fc772..4095ff3138946450dea1d9587a21bfc06122b659 100644 --- a/mo/web/templates/parts/org_submit_warning.html +++ b/mo/web/templates/parts/org_submit_warning.html @@ -1,10 +1,10 @@ -{% if not sc.allow_upload_solutions and sc.round.state == RoundState.running %} +{% if not ctx.rights.can_upload_solutions() and ctx.round.state == RoundState.running %} <p class='alert alert-warning'> Soutěž stále běží. Odevzdané úlohy se ještě mohou měnit. </p> {% endif %} -{% if not sc.allow_upload_feedback and sc.round.state == RoundState.grading %} +{% if not ctx.rights.can_upload_feedback() and ctx.round.state == RoundState.grading %} <p class='alert alert-warning'> Opravování stále běží. Opravené úlohy a body se ještě mohou měnit. </p> diff --git a/mo/web/user.py b/mo/web/user.py index ee53d404bccf1edcc9f0fd24b9aeb363a922ee66..0ff79c8f55967a055ff8a378b009d34902861534 100644 --- a/mo/web/user.py +++ b/mo/web/user.py @@ -8,7 +8,6 @@ import werkzeug.exceptions import wtforms from wtforms.validators import Required -import mo import mo.config as config import mo.email import mo.db as db @@ -41,7 +40,7 @@ def load_pcrs() -> List[Tuple[db.Participation, db.Contest, db.Round]]: .join(db.Contest, db.Contest.master_contest_id == db.Participation.contest_id) .join(db.Round) .filter(db.Participation.user == g.user) - .filter(db.Round.year == mo.current_year) + .filter(db.Round.year == config.CURRENT_YEAR) .options(joinedload(db.Contest.place)) .order_by(db.Round.category, db.Round.seq, db.Round.part) .all()) @@ -52,7 +51,7 @@ def user_join(): available_rounds: List[db.Round] = ( db.get_session().query(db.Round) .select_from(db.Round) - .filter_by(year=mo.current_year) + .filter_by(year=config.CURRENT_YEAR) .filter(db.Round.enroll_mode.in_([db.RoundEnrollMode.register, db.RoundEnrollMode.confirm])) .filter_by(state=db.RoundState.running) .order_by(db.Round.category, db.Round.seq) @@ -87,7 +86,7 @@ def user_join_round(round_id): if not round: raise werkzeug.exceptions.NotFound() - if (round.year != mo.current_year + if (round.year != config.CURRENT_YEAR or round.part > 1 or round.enroll_mode not in [db.RoundEnrollMode.register, db.RoundEnrollMode.confirm] or round.state != db.RoundState.running): @@ -155,7 +154,7 @@ def user_join_round(round_id): def join_create_pant(form: JoinRoundForm) -> db.Participant: assert form.school.place is not None pant = db.Participant(user=g.user, - year=mo.current_year, + year=config.CURRENT_YEAR, school_place=form.school.place, grade=form.grade.data, birth_year=form.birth_year.data) @@ -175,7 +174,7 @@ def join_create_contest(round: db.Round, pant: db.Participant) -> db.Contest: place = pant.school_place if place.level != round.level: - parents = db.get_place_parents(pant.school_place) + parents = db.get_place_ancestors(pant.school_place) places = [p for p in parents if p.level == round.level] assert len(places) == 1 place = places[0] diff --git a/mo/web/util.py b/mo/web/util.py index d6c0eff2142b3b8ce73409d07690c586d60bdd70..1be8eb8027ee958df4216f8bde64904d9e1f2938 100644 --- a/mo/web/util.py +++ b/mo/web/util.py @@ -88,7 +88,7 @@ def org_paper_link(contest_or_id: Union[db.Contest, int], contest_or_id = contest_or_id.contest_id return url_for('org_submit_paper' if not orig else 'org_submit_paper_orig', - contest_id=contest_or_id, + ct_id=contest_or_id, paper_id=paper.paper_id, site_id=site.place_id if site else None, filename=_task_paper_filename(user, paper))