diff --git a/bin/create-user b/bin/create-user index 09fbe9e10eaa73c52eef42de7a76e1383aee81e2..7a8851b4a7b4d5eba08eb895387b72adddea61fb 100755 --- a/bin/create-user +++ b/bin/create-user @@ -4,6 +4,7 @@ import argparse import sys import mo.db as db +import mo.email import mo.users import mo.util @@ -13,8 +14,7 @@ parser.add_argument(dest='first_name', help='křestní jméno (jedno nebo více) parser.add_argument(dest='last_name', help='příjmení (jedno nebo více)') parser.add_argument('--org', default=False, action='store_true', help='přidělí uživateli organizátorská práva') parser.add_argument('--admin', default=False, action='store_true', help='přidělí uživateli správcovská práva') -parser.add_argument('--passwd', type=str, help='nastaví počáteční heslo') -parser.add_argument('--mail', default=False, action='store_true', help='pošle uživateli mail o založení účtu') +parser.add_argument('--passwd', type=str, help='nastaví počáteční heslo (jinak pošle aktivační mail)') args = parser.parse_args() email = mo.users.normalize_email(args.email) @@ -45,11 +45,9 @@ mo.util.log(db.LogType.user, user.user_id, { if args.passwd is not None: mo.users.set_password(user, args.passwd) - -if args.mail: - token = mo.users.ask_reset_password(user) + token = mo.users.make_activation_token(user) session.commit() -if args.mail: - mo.util.send_new_account_email(user, token) +if args.passwd is None: + mo.email.send_new_account_email(user, token) diff --git a/bin/reset-user b/bin/reset-user index 0b6f8c7085edba31ab12e3d3b63f13d417d7c463..bea8fe06f8d75ac09566f699700a399d7b85e740 100755 --- a/bin/reset-user +++ b/bin/reset-user @@ -1,16 +1,15 @@ #!/usr/bin/env python3 import argparse -import sys -import mo.config as config +import mo.config +import mo.email import mo.db as db import mo.users import mo.util -parser = argparse.ArgumentParser(description='Resetuje uživateli heslo a pošle mail') +parser = argparse.ArgumentParser(description='Pošle uživateli nový aktivační mail') parser.add_argument(dest='email', help='e-mailová adresa') -parser.add_argument('--new', default=False, action='store_true', help='pošle mail o založení účtu') parser.add_argument('--mail-instead', metavar='EMAIL', default=None, help='pošle mail někomu jinému') args = parser.parse_args() @@ -22,13 +21,10 @@ user = mo.users.user_by_email(args.email) if user is None: mo.util.die('Tento uživatel neexistuje') -token = mo.users.ask_reset_password(user) +token = mo.users.make_activation_token(user) session.commit() if args.mail_instead: mo.config.MAIL_INSTEAD = args.mail_instead -if args.new: - mo.util.send_new_account_email(user, token) -else: - mo.util.send_password_reset_email(user, token) +mo.email.send_new_account_email(user, token) diff --git a/db/db.ddl b/db/db.ddl index fd3ce6c2c8f86c9b736993a36e2911491ddd23f6..b20d86522fb9feecd227c592cc519bcf22335de7 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -1,9 +1,19 @@ -- CREATE ROLE mo_osmo LOGIN PASSWORD 'pass'; -- CREATE DATABASE mo_osmo WITH OWNER=mo_osmo; -- GRANT mo_osmo TO some_admin; +-- CREATE EXTENSION unaccent; SET ROLE mo_osmo; +-- Funkce pro odakcentování textu pomocí extension unaccent. +-- Je immutable, takže se dá používat i v indexech. +-- Zdroj: http://stackoverflow.com/questions/11005036/does-postgresql-support-accent-insensitive-collations +CREATE OR REPLACE FUNCTION f_unaccent(text) + RETURNS text AS +$func$ +SELECT unaccent('unaccent', $1) +$func$ LANGUAGE sql IMMUTABLE SET search_path = public, pg_temp; + -- Uživatelský účet CREATE TABLE users ( user_id serial PRIMARY KEY, @@ -15,7 +25,7 @@ CREATE TABLE users ( is_test boolean NOT NULL DEFAULT false, -- testovací účastník, není vidět ve výsledkovkách created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, last_login_at timestamp with time zone DEFAULT NULL, - reset_at timestamp with time zone DEFAULT NULL, -- poslední požadavek na reset hesla + reset_at timestamp with time zone DEFAULT NULL, -- poslední reset/aktivace nebo žádost o ně password_hash varchar(255) DEFAULT NULL, -- heš hesla (je-li nastaveno) note text NOT NULL DEFAULT '' -- poznámka viditelná pro orgy ); @@ -45,6 +55,8 @@ CREATE TABLE places ( ); CREATE INDEX places_parent_index ON places (parent); +-- XXX: Potřebujeme operator class text_pattern_ops, aby index fungoval na prefixové LIKE +CREATE INDEX places_noacc_index ON places ((lower(f_unaccent(name))) text_pattern_ops); -- Rekurzivní dotaz na nadřazené regiony: -- WITH RECURSIVE parent_regions(parent, place_id) AS ( @@ -89,6 +101,12 @@ CREATE TYPE score_mode AS ENUM ( 'mo' -- jednoznačné pořadí podle pravidel MO ); +CREATE TYPE enroll_mode AS ENUM ( -- režim přihlašování účastníků + 'manual', -- přihlašuje organizátor + 'register', -- účastník se registruje + 'confirm' -- účastník se registruje, ale org ho musí potvrdit +); + CREATE TABLE rounds ( round_id serial PRIMARY KEY, master_round_id int DEFAULT NULL REFERENCES rounds(round_id), @@ -109,6 +127,8 @@ CREATE TABLE rounds ( score_successful_limit int DEFAULT NULL, -- bodový limit na označení za úspěšného řešitele points_step numeric(2,1) NOT NULL DEFAULT 1, -- s jakou přesností jsou přidělovány body (celé aneb 1, 0.5, 0.1) has_messages boolean NOT NULL DEFAULT false, -- má zprávičky + enroll_mode enroll_mode NOT NULL DEFAULT 'manual', -- režim přihlašování (pro vyšší kola vždy 'manual') + enroll_advert varchar(255) NOT NULL DEFAULT '', -- popis v přihlašovacím formuláři UNIQUE (year, category, seq, part) ); @@ -133,17 +153,19 @@ CREATE TABLE participants ( school int NOT NULL REFERENCES places(place_id), birth_year int NOT NULL, grade varchar(20) NOT NULL, -- třída ve tvaru "X/Y" + registered_on timestamp with time zone DEFAULT NULL, -- kdy se účastník přihlásil (NULL, pokud ho přihlásil organizátor) PRIMARY KEY (user_id, year) ); -- Účast v soutěžním kole CREATE TYPE part_state AS ENUM ( - 'registered', -- sám se přihlásil - 'invited', -- pozván - 'refused', -- odmítl účast - 'present', -- soutěžil - 'absent', -- bez omluvy nedorazil + 'registered', -- sám se přihlásil, čeká na potvrzení organizátorem + -- 'invited', -- pozván (už nepoužíváme) + 'active', -- soutěží (přihlášku zadal/potvrdil organizátor) + 'refused', -- organizátor odmítl přihlášku + -- 'present', -- soutěžil (už nepoužíváme) + 'absent', -- nedorazil 'disqualified' -- diskvalifikovaný ); @@ -312,3 +334,24 @@ CREATE TABLE messages ( markdown text NOT NULL, html text NOT NULL ); + +-- Požadavky na registraci a změny vlastností účtu + +CREATE TYPE reg_req_type AS ENUM ( + 'register', + 'change_email', + 'reset_password' +); + +CREATE TABLE reg_requests ( + reg_id serial PRIMARY KEY, + type reg_req_type NOT NULL, + created_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone NOT NULL, + used_at timestamp with time zone DEFAULT NULL, + email varchar(255) DEFAULT NULL, -- adresa, kterou potvrzujeme + captcha_token varchar(255) DEFAULT NULL, -- token pro fázi 1 registrace + email_token varchar(255) UNIQUE NOT NULL, -- token pro fázi 2 registrace + user_id int DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE, + client varchar(255) NOT NULL -- kdo si registraci vyžádal +); diff --git a/db/upgrade-20210712.sql b/db/upgrade-20210712.sql new file mode 100644 index 0000000000000000000000000000000000000000..ba6a4bf99c41e051a1479908b03299212f7a9c99 --- /dev/null +++ b/db/upgrade-20210712.sql @@ -0,0 +1,35 @@ +SET ROLE 'mo_osmo'; + +CREATE TYPE enroll_mode AS ENUM ( -- režim přihlašování účastníků + 'manual', -- přihlašuje organizátor + 'register', -- účastník se registruje + 'confirm' -- účastník se registruje, ale org ho musí potvrdit +); + +ALTER TABLE rounds ADD COLUMN enroll_mode enroll_mode NOT NULL DEFAULT 'manual'; +ALTER TABLE rounds ADD COLUMN enroll_advert varchar(255) NOT NULL DEFAULT ''; + +ALTER TYPE part_state ADD VALUE 'active' AFTER 'invited'; + +ALTER TABLE participants ADD COLUMN registered_on timestamp with time zone DEFAULT NULL; + +UPDATE participations SET state='active' WHERE state IN ('registered', 'invited'); + +CREATE TYPE reg_req_type AS ENUM ( + 'register', + 'change_email', + 'reset_passwd' +); + +CREATE TABLE reg_requests ( + reg_id serial PRIMARY KEY, + type reg_req_type NOT NULL, + created_at timestamp with time zone NOT NULL, + expires_at timestamp with time zone NOT NULL, + used_at timestamp with time zone DEFAULT NULL, + email varchar(255) DEFAULT NULL, + captcha_token varchar(255) DEFAULT NULL, + email_token varchar(255) UNIQUE NOT NULL, + user_id int DEFAULT NULL REFERENCES users(user_id) ON DELETE CASCADE, + client varchar(255) NOT NULL +); diff --git a/db/upgrade-20210819.sql b/db/upgrade-20210819.sql new file mode 100644 index 0000000000000000000000000000000000000000..5fa30fbde2453527317f8b821fd5a7a7ac76884d --- /dev/null +++ b/db/upgrade-20210819.sql @@ -0,0 +1,12 @@ +SET ROLE 'mo_osmo'; + +-- Musí udělat správce (ale uvnitř naší DB): +-- CREATE EXTENSION unaccent; + +CREATE OR REPLACE FUNCTION f_unaccent(text) + RETURNS text AS +$func$ +SELECT unaccent('unaccent', $1) +$func$ LANGUAGE sql IMMUTABLE SET search_path = public, pg_temp; + +CREATE INDEX places_noacc_index ON places ((lower(f_unaccent(name))) text_pattern_ops); diff --git a/etc/config.py.example b/etc/config.py.example index 9b9e4ec05976ee108f147230b9bcea884241682c..f7db910c8d24c676a30bba7a76e80c983ab6f1db 100644 --- a/etc/config.py.example +++ b/etc/config.py.example @@ -42,12 +42,14 @@ MAX_BATCH_CONTENT_LENGTH = 1000000000 # Adresář, do kterého ukládáme data (pro vývoj relativní, pro instalaci absolutní) DATA_DIR = 'data' -# Jak často se má provádět periodická kontrola dávek [s] -JOB_GC_PERIOD = 60 +# Jak často se má spouštět garbage collector na dávky a tokeny [s] +GC_PERIOD = 60 # Za jak dlouho expiruje dokončená dávka [min] JOB_EXPIRATION = 5 -# Automatické přihlašování účastníků do testovací soutěže -# (kolo aktuální_ročník-T-1, celostátní soutěž) -AUTO_REGISTER_TEST = False +# Kolik nejvýše dovolujeme registrací za minutu +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 diff --git a/mo/__init__.py b/mo/__init__.py index 4feeb4d59dae535b432a9997001356cac8336a09..98d16b9ad30321087b30790a71fe913ae7eec036 100644 --- a/mo/__init__.py +++ b/mo/__init__.py @@ -3,7 +3,7 @@ import datetime # Aktuální ročník -current_year = 70 +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 c1de5014635fe58d8c326daed91d22dcad52c7cc..000d01ca6fd196edc465f7d3a1afa00177514c47 100644 --- a/mo/db.py +++ b/mo/db.py @@ -16,12 +16,13 @@ from sqlalchemy.orm.attributes import get_history from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.sql.expression import CTE +from sqlalchemy.sql.functions import ReturnTypeFromArgs from sqlalchemy.sql.sqltypes import Numeric from typing import Optional, List, Tuple import mo from mo.place_level import place_levels, PlaceLevel -from mo.util_format import timedelta, time_and_timedelta +from mo.util_format import timeformat_short, timedelta, time_and_timedelta # HACK: Work-around for https://github.com/dropbox/sqlalchemy-stubs/issues/114 from typing import TYPE_CHECKING, TypeVar, Type, Any @@ -34,6 +35,11 @@ else: from sqlalchemy import Enum +# Funkce f_unaccent je definovaná v db.ddl, naučme Alchymii volat ji: +class f_unaccent(ReturnTypeFromArgs): + pass + + Base = declarative_base() metadata = Base.metadata @@ -99,6 +105,7 @@ class Place(Base): nuts = Column(String(255), unique=True, server_default=text("NULL::character varying")) note = Column(Text, nullable=False, server_default=text("''::text")) + parent_place = relationship('Place', primaryjoin='Place.parent == Place.place_id', remote_side='Place.place_id') children = relationship('Place') school = relationship('School', uselist=False, back_populates='place') @@ -203,6 +210,22 @@ round_score_mode_names = { } +class RoundEnrollMode(MOEnum): + manual = auto() + register = auto() + confirm = auto() + + def friendly_name(self) -> str: + return round_enroll_mode_names[self] + + +round_enroll_mode_names = { + RoundEnrollMode.manual: "Jen organizátoři", + RoundEnrollMode.register: "Účastníci sami", + RoundEnrollMode.confirm: "Potvrzení organizátorem", +} + + # V DB jako numeric(2,1), používá se tak snadněji, než enum round_points_step_names = { decimal.Decimal('1'): "Celé body", @@ -237,6 +260,8 @@ class Round(Base): score_successful_limit = Column(Numeric) points_step = Column(Numeric, nullable=False) has_messages = Column(Boolean, nullable=False, server_default=text("false")) + enroll_mode = Column(Enum(RoundEnrollMode, name='enroll_mode'), nullable=False, server_default=text("'basic'::enroll_mode")) + enroll_advert = Column(String(255), nullable=False, server_default=text("''::text")) master = relationship('Round', primaryjoin='Round.master_round_id == Round.round_id', remote_side='Round.round_id', post_update=True) @@ -281,6 +306,14 @@ class Round(Base): return round_points_step_names[self.points_step] return str(self.points_step) + def format_times(self) -> str: + times = [] + if self.ct_tasks_start is not None: + times.append('od ' + timeformat_short(self.ct_tasks_start)) + if self.ct_submit_end is not None: + times.append('do ' + timeformat_short(self.ct_submit_end)) + return " ".join(times) + class User(Base): __tablename__ = 'users' @@ -403,6 +436,7 @@ class Participant(Base): school = Column(Integer, ForeignKey('places.place_id'), nullable=False) birth_year = Column(Integer, nullable=False) grade = Column(String(20), nullable=False) + registered_on = Column(DateTime(True)) user = relationship('User') school_place = relationship('Place', primaryjoin='Participant.school == Place.place_id') @@ -410,9 +444,8 @@ class Participant(Base): class PartState(MOEnum): registered = auto() - invited = auto() + active = auto() refused = auto() - present = auto() absent = auto() disqualified = auto() @@ -422,9 +455,8 @@ class PartState(MOEnum): part_state_names = { PartState.registered: 'přihlášený', - PartState.invited: 'pozvaný', + PartState.active: 'soutěží', PartState.refused: 'odmítnutý', - PartState.present: 'přítomný', PartState.absent: 'nepřítomný', PartState.disqualified: 'diskvalifikovaný', } @@ -510,6 +542,12 @@ class UserRole(Base): return " ".join(parts) + def applies_to(self, at: Optional[Place] = None, year: Optional[int] = None, cat: Optional[str] = None, seq: Optional[int] = None) -> bool: + return ((at is None or self.place_id == at.place_id) + and (self.year is None or year is None or self.year == year) + and (self.category is None or cat is None or self.category == cat or (self.category == 'Z' and cat.startswith('Z'))) + and (self.seq is None or seq is None or self.seq == seq)) + class PaperType(MOEnum): solution = auto() @@ -644,6 +682,29 @@ class Message(Base): created_by_user = relationship('User') +class RegReqType(MOEnum): + register = auto() + change_email = auto() + reset_passwd = auto() + + +class RegRequest(Base): + __tablename__ = 'reg_requests' + + reg_id = Column(Integer, primary_key=True, server_default=text("nextval('reg_requests_reg_id_seq'::regclass)")) + type = Column(Enum(RegReqType, name='reg_req_type'), nullable=False) + created_at = Column(DateTime(True), nullable=False) + expires_at = Column(DateTime(True), nullable=False) + used_at = Column(DateTime(True)) + captcha_token = Column(Text) + email = Column(Text) + email_token = Column(Text, nullable=False, unique=True) + user_id = Column(Integer, ForeignKey('users.user_id')) + client = Column(Text, nullable=False) + + user = relationship('User') + + _engine: Optional[Engine] = None _session: Optional[Session] = None flask_db: Any = None diff --git a/mo/email.py b/mo/email.py new file mode 100644 index 0000000000000000000000000000000000000000..14660593e398ef3a92c71ac12569afb198995e89 --- /dev/null +++ b/mo/email.py @@ -0,0 +1,156 @@ +# Rozesílání e-mailových notifikací všeho druhu + +import datetime +import email.message +import email.headerregistry +import subprocess +import textwrap +import urllib.parse + +import mo.db as db +import mo.config as config +from mo.util import logger + + +def send_user_email(user: db.User, subject: str, body: str) -> bool: + logger.info(f'Mail: "{subject}" -> {user.email}') + + mail_from = getattr(config, 'MAIL_FROM', None) + if mail_from is None: + logger.error('Mail: V configu chybí nastavení MAIL_FROM') + return False + + msg = email.message.EmailMessage() + msg['From'] = email.headerregistry.Address( + display_name='Odevzdávací Systém MO', + addr_spec=mail_from, + ) + msg['To'] = [ + email.headerregistry.Address( + display_name=user.full_name(), + addr_spec=user.email, + ) + ] + msg['Reply-To'] = email.headerregistry.Address( + display_name='Správce OSMO', + addr_spec=config.MAIL_CONTACT, + ) + msg['Subject'] = 'OSMO – ' + subject + msg['Date'] = datetime.datetime.now() + + msg.set_content(body, cte='quoted-printable') + + mail_instead = getattr(config, 'MAIL_INSTEAD', None) + if mail_instead is None: + send_to = user.email + else: + send_to = mail_instead + + sm = subprocess.Popen( + [ + '/usr/sbin/sendmail', + '-oi', + '-f', + mail_from, + send_to, + ], + stdin=subprocess.PIPE, + ) + sm.communicate(msg.as_bytes()) + + if sm.returncode != 0: + logger.error('Mail: Sendmail failed with return code {}'.format(sm.returncode)) + return False + + return True + + +def activate_url(token: str) -> str: + return config.WEB_ROOT + 'acct/activate?' + urllib.parse.urlencode({'token': token}, safe=':') + + +def confirm_url(type: str, token: str) -> str: + return config.WEB_ROOT + f'acct/confirm/{type}?' + urllib.parse.urlencode({'token': token}, safe=':') + + +def contestant_list_url(contest: db.Contest, registered_only: bool) -> str: + url = config.WEB_ROOT + f'org/contest/c/{contest.contest_id}/ucastnici' + if registered_only: + url += '?participation_state=registered' + return url + + +def send_new_account_email(user: db.User, token: str) -> bool: + return send_user_email(user, 'Založen nový účet', textwrap.dedent('''\ + Vítejte! + + Právě Vám byl založen účet v Odevzdávacím systému Matematické olympiády. + Nastavte si prosím heslo na následující stránce: + + {} + + Váš OSMO + '''.format(activate_url(token)))) + + +def send_password_reset_email(user: db.User, token: str) -> bool: + return send_user_email(user, 'Obnova hesla', textwrap.dedent('''\ + Někdo (pravděpodobně Vy) požádal o obnovení hesla k Vašemu účtu v Odevzdávacím + systému Matematické olympiády. Heslo si můžete nastavit, případně požadavek + zrušit, na následující stránce: + + {} + + Váš OSMO + '''.format(confirm_url('p', token)))) + + +def send_confirm_create_email(user: db.User, token: str) -> bool: + return send_user_email(user, 'Založení účtu', textwrap.dedent('''\ + Někdo (pravděpodobně Vy) požádal o založení účtu s touto e-mailovou adresou + v Odevzdávacím systému Matematické olympiády. Pokud účet chcete založit, + následujte tento odkaz: + + {} + + Váš OSMO + '''.format(confirm_url('r', token)))) + + +def send_confirm_change_email(user: db.User, token: str) -> bool: + return send_user_email(user, 'Změna e-mailové adresy', textwrap.dedent('''\ + Někdo (pravděpodobně Vy) požádal o nastavení e-mailové adresy k účtu + v Odevzdávacím systému Matematické olympiády na tuto adresu. + Pokud změnu chcete provést, následujte tento odkaz: + + {} + + Váš OSMO + '''.format(confirm_url('e', token)))) + + +def send_join_notify_email(dest: db.User, who: db.User, contest: db.Contest) -> bool: + round = contest.round + place = contest.place + if contest.round.enroll_mode == db.RoundEnrollMode.confirm: + confirm = 'Přihlášku je potřeba potvrdit v seznamu účastníků soutěže.' + url = 'Přihlášky k potvrzení: ' + contestant_list_url(contest, True) + else: + confirm = 'Přihláška byla schválena automaticky.' + url = 'Seznam účastníků: ' + contestant_list_url(contest, False) + + return send_user_email(dest, f'Nový účastník kategorie {round.category}', textwrap.dedent(f'''\ + Nový účastník se přihlásil do MO v oblasti, kterou garantujete. + + Jméno: {who.full_name()} + E-mail: {who.email} + Kategorie: {round.category} + Kolo: {round.name} + Místo: {place.name} + + {confirm} + + {url} + + Váš OSMO + ''')) diff --git a/mo/imports.py b/mo/imports.py index 332845371d95707b1e7413dc63a52414fe76b523..bc09f3b0b99c64b3720788bba4e5a3f3dace7654 100644 --- a/mo/imports.py +++ b/mo/imports.py @@ -10,6 +10,7 @@ from typing import List, Optional, Any, Dict, Type, Union import mo.csv from mo.csv import FileFormat, MissingHeaderError import mo.db as db +import mo.email import mo.rights import mo.users import mo.util @@ -372,9 +373,9 @@ class Import: for uid in self.new_user_ids: u = sess.query(db.User).get(uid) if u and not u.password_hash and not u.reset_at: - token = mo.users.ask_reset_password(u) + token = mo.users.make_activation_token(u) sess.commit() - mo.util.send_new_account_email(u, token) + mo.email.send_new_account_email(u, token) else: sess.rollback() diff --git a/mo/rights.py b/mo/rights.py index 29553db03b62c9409ca58aeba7be6db0bc36d827..85531c939e79deca4fd56533ff7467a3a8c2790a 100644 --- a/mo/rights.py +++ b/mo/rights.py @@ -325,10 +325,7 @@ class Gatekeeper: rights.rights = set() def try_role(role: db.UserRole, at: Optional[db.Place]): - if ((at is None or role.place_id == at.place_id) - and (role.year is None or year is None or role.year == year) - and (role.category is None or cat is None or role.category == cat or (role.category == 'Z' and cat.startswith('Z'))) - and (role.seq is None or seq is None or role.seq == seq) + if (role.applies_to(at=at, year=year, cat=cat, seq=seq) and (min_role is None or role_order_by_type[min_role] >= role_order_by_type[role.role])): rights.user_roles.append(role) r = roles_by_type[role.role] diff --git a/mo/score.py b/mo/score.py index 01a26d786d8157cb36364e19f69d24ace9abf42c..e9bc006d0b5239aca53467a04a51dc9c114218cf 100644 --- a/mo/score.py +++ b/mo/score.py @@ -106,7 +106,7 @@ class Score: def __init__( self, round: db.Round, contest: Optional[db.Contest] = None, # Ze kterých stavů chceme výsledkovku počítat - part_states: List[db.PartState] = [db.PartState.registered, db.PartState.invited, db.PartState.present], + part_states: List[db.PartState] = [db.PartState.registered, db.PartState.active], ): self.round = round self.contest = contest diff --git a/mo/tokens.py b/mo/tokens.py index afe36b846b40dbfadf2b81672f62a16e6da87d31..97a7077544e26d86f2fad2545163deab08245db8 100644 --- a/mo/tokens.py +++ b/mo/tokens.py @@ -1,5 +1,6 @@ # Podepsané tokeny +import hashlib import hmac import urllib.parse from typing import Optional, List @@ -8,9 +9,7 @@ import mo.config as config def _sign_token(token: str, use: str) -> str: - key = '-'.join(('sign-token', use, config.SECRET_KEY)) - mac = hmac.HMAC(key.encode('us-ascii'), token.encode('us-ascii'), 'sha256') - return mac.hexdigest()[:16] + return sign(token, 'token-' + use) def sign_token(fields: List[str], use: str) -> str: @@ -27,3 +26,18 @@ def verify_token(token: str, use: str) -> Optional[List[str]]: if _sign_token(':'.join(enc_fields), use) != sign: return None return [urllib.parse.unquote(f) for f in enc_fields] + + +def sign(msg: str, use: str) -> str: + """Podpis parametrizovaný tajným klíčem a účelem.""" + + key = use + '#' + config.SECRET_KEY + mac = hmac.HMAC(key.encode('us-ascii'), msg.encode('us-ascii'), 'sha256') + return mac.hexdigest()[:16] + + +def hash(msg: str, use: str) -> str: + """Hešovací funkce parametrizovaná tajným klíčem a účelem.""" + + m = '#'.join((use, config.SECRET_KEY, msg)).encode('us-ascii') + return hashlib.sha256(m).hexdigest() diff --git a/mo/users.py b/mo/users.py index 6fef5ec9ad8280e6f5bf6693abdb76782885de6e..f7d789c73f28f501dce83274d97e28cd4dee110e 100644 --- a/mo/users.py +++ b/mo/users.py @@ -2,12 +2,15 @@ import bcrypt import datetime +import dateutil.tz import email.errors import email.headerregistry import re +import secrets from typing import Optional, Tuple import mo +import mo.config as config import mo.db as db import mo.util from mo.util import logger @@ -107,7 +110,7 @@ def find_or_create_participation(user: db.User, contest: db.Contest, place: Opti is_new = pions == [] if is_new: - pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.invited) + pion = db.Participation(user=user, contest=contest, place_id=place.place_id, state=db.PartState.active) sess.add(pion) logger.info(f'{reason.title()}: Založena účast user=#{user.user_id} contest=#{contest.contest_id} place=#{place.place_id}') mo.util.log( @@ -165,18 +168,24 @@ def user_by_uid(uid: int) -> db.User: return db.get_session().query(db.User).get(uid) -def set_password(user: db.User, passwd: str): +password_help = 'Heslo musí mít alespoň 8 znaků. Doporučujeme kombinovat velká a malá písmena a číslice.' + + +def validate_password(passwd: str) -> bool: + return len(passwd) >= 8 + + +def set_password(user: db.User, passwd: str, reset: bool = False): salt = bcrypt.gensalt() hashed = bcrypt.hashpw(passwd.encode('utf-8'), salt) user.password_hash = hashed.decode('us-ascii') - user.reset_at = None - user.last_login_at = datetime.datetime.now() - - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={"action": "set-passwd"}, - ) + if reset: + user.reset_at = mo.now + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={'action': 'do-reset'}, + ) def check_password(user: db.User, passwd: str): @@ -185,63 +194,68 @@ def check_password(user: db.User, passwd: str): def login(user: db.User): - user.last_login_at = datetime.datetime.now() - user.reset_at = None - - -def ask_reset_password(user: db.User) -> str: - user.reset_at = datetime.datetime.now() - when = int(user.reset_at.timestamp()) - token = mo.tokens.sign_token([str(user.user_id), str(when)], 'reset') - - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'ask-reset'}, - ) + user.last_login_at = mo.now - return token +def make_activation_token(user: db.User) -> str: + user.reset_at = mo.now + when = int(mo.now.timestamp()) + return mo.tokens.sign_token([str(user.user_id), str(when)], 'activate') -def check_reset_password(token: str) -> Optional[db.User]: - # Někteří klienti při kopírování adresy z mailu do prohlížeče - # přidávají divné Unicodové znaky (přepnutí směru psaní atd., viz issue #58). - token = re.sub(r'[^!-~]', "", token) - fields = mo.tokens.verify_token(token, 'reset') +def check_activation_token(token: str) -> Optional[db.User]: + token = mo.util.clean_up_token(token) + fields = mo.tokens.verify_token(token, 'activate') if not fields or len(fields) != 2: return None - user = db.get_session().query(db.User).filter_by(user_id=int(fields[0])).first() + user_id = int(fields[0]) + token_time = datetime.datetime.fromtimestamp(int(fields[1]), tz=dateutil.tz.UTC) - if user.password_hash is None: - reset_token_validity_time = datetime.timedelta(days=28) + user = user_by_uid(user_id) + if not user: + return None + elif token_time < mo.now - datetime.timedelta(days=28): + return None else: - reset_token_validity_time = datetime.timedelta(hours=24) + return user - now = datetime.datetime.now().astimezone() - if (user - and user.reset_at is not None - and fields[1] == str(int(user.reset_at.timestamp())) - and now - user.reset_at < reset_token_validity_time): - return user - else: +def new_reg_request(type: db.RegReqType, client: str) -> Optional[db.RegRequest]: + sess = db.get_session() + + # Zatím jen jednoduchý rate limit, časem možno vylepšit + in_last_minute = db.get_count(sess.query(db.RegRequest).filter(db.RegRequest.created_at >= mo.now - datetime.timedelta(minutes=1))) + if in_last_minute >= config.REG_MAX_PER_MINUTE: return None + email_token = mo.tokens.sign_token([str(int(mo.now.timestamp())), secrets.token_hex(16)], 'reg-request') -def cancel_reset_password(user: db.User): - user.reset_at = None - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'cancel-reset'}, + return db.RegRequest( + type=type, + created_at=mo.now, + expires_at=mo.now + datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY), + email_token=email_token, + client=client, ) -def do_reset_password(user: db.User): - user.reset_at = None - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'do-reset'}, - ) +def expire_reg_requests(): + sess = db.get_session() + conn = sess.connection() + table = db.RegRequest.__table__ + conn.execute(table.delete().where(table.c.expires_at < mo.now)) + sess.commit() + + +def request_reset_password(user: db.User, client: str) -> Optional[db.RegRequest]: + logger.info('Login: Požadavek na reset hesla pro <%s>', user.email) + rr = new_reg_request(db.RegReqType.reset_passwd, client) + if rr: + db.get_session().add(rr) + rr.user_id = user.user_id + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={'action': 'ask-reset'}, + ) + return rr diff --git a/mo/util.py b/mo/util.py index 10c2d63896d8f70cb31de877a5a4ced9458a4e7f..56223feec89de5e9f8660f67b658dc0be0d5469a 100644 --- a/mo/util.py +++ b/mo/util.py @@ -4,18 +4,13 @@ from dataclasses import dataclass import datetime import decimal import dateutil.tz -import email.message -import email.headerregistry import locale import logging import os import re import secrets -import subprocess import sys from typing import Any, Optional, NoReturn, Tuple, List -import textwrap -import urllib.parse import mo import mo.db as db @@ -60,88 +55,6 @@ def log(type: db.LogType, what: int, details: Any): db.get_session().add(entry) -def send_user_email(user: db.User, subject: str, body: str) -> bool: - logger.info(f'Mail: "{subject}" -> {user.email}') - - mail_from = getattr(config, 'MAIL_FROM', None) - if mail_from is None: - logger.error('Mail: V configu chybí nastavení MAIL_FROM') - return False - - msg = email.message.EmailMessage() - msg['From'] = email.headerregistry.Address( - display_name='Odevzdávací Systém MO', - addr_spec=mail_from, - ) - msg['To'] = [ - email.headerregistry.Address( - display_name=user.full_name(), - addr_spec=user.email, - ) - ] - msg['Reply-To'] = email.headerregistry.Address( - display_name='Správce OSMO', - addr_spec=config.MAIL_CONTACT, - ) - msg['Subject'] = 'OSMO – ' + subject - msg['Date'] = datetime.datetime.now() - - msg.set_content(body, cte='quoted-printable') - - mail_instead = getattr(config, 'MAIL_INSTEAD', None) - if mail_instead is None: - send_to = user.email - else: - send_to = mail_instead - - sm = subprocess.Popen( - [ - '/usr/sbin/sendmail', - '-oi', - '-f', - mail_from, - send_to, - ], - stdin=subprocess.PIPE, - ) - sm.communicate(msg.as_bytes()) - - if sm.returncode != 0: - logger.error('Mail: Sendmail failed with return code {}'.format(sm.returncode)) - return False - - return True - - -def password_reset_url(token: str) -> str: - return config.WEB_ROOT + 'auth/reset?' + urllib.parse.urlencode({'token': token}, safe=':') - - -def send_new_account_email(user: db.User, token: str) -> bool: - return send_user_email(user, 'Založen nový účet', textwrap.dedent('''\ - Vítejte! - - Právě Vám byl založen účet v Odevzdávacím systému Matematické olympiády. - Nastavte si prosím heslo na následující stránce: - - {} - - Váš OSMO - '''.format(password_reset_url(token)))) - - -def send_password_reset_email(user: db.User, token: str) -> bool: - return send_user_email(user, 'Obnova hesla', textwrap.dedent('''\ - Někdo (pravděpodobně Vy) požádal o obnovení hesla k Vašemu účtu v Odevzdávacím - systému Matematické olympiády. Heslo si můžete nastavit, případně požadavek - zrušit, na následující stránce: - - {} - - Váš OSMO - '''.format(password_reset_url(token)))) - - def die(msg: str) -> NoReturn: print(msg, file=sys.stderr) sys.exit(1) @@ -268,3 +181,9 @@ def parse_int_list(a: str, maxim: int = 200) -> List[int]: raise mo.CheckError("Větší číslo nemůže být před menším") r += [c[0]] if len(c) == 1 else range(c[0], c[1] + 1) return r + + +def clean_up_token(token: str) -> str: + # Někteří klienti při kopírování adresy z mailu do prohlížeče + # přidávají divné Unicodové znaky (přepnutí směru psaní atd., viz issue #58). + return re.sub(r'[^!-~]', "", token) diff --git a/mo/util_format.py b/mo/util_format.py index 7b20df1ca28b965a0c012909aa50377e20125c95..149e77a1d2a93166d0b454fe95bd1bda17b45224 100644 --- a/mo/util_format.py +++ b/mo/util_format.py @@ -41,6 +41,13 @@ def timeformat(dt: datetime) -> str: return dt.astimezone().strftime("%Y-%m-%d %H:%M") +def timeformat_short(dt: datetime) -> str: + if dt is None: + return '–' + else: + return dt.astimezone().strftime("%Y-%m-%d") + + def timedelta(d: datetime, ref: Optional[datetime] = None, descriptive: bool = False) -> str: """Vyrábí česky formátované řetězece 'za 3 minuty', 'před 27 dny' a podobně z rozdílu daného datetime a referenčního času (například now). diff --git a/mo/web/__init__.py b/mo/web/__init__.py index 0121788742a13b59bd58ebf83ff33f27e7eb9a0e..782dba3ebd9d1dc16d5b6fda3839e897eca294c3 100644 --- a/mo/web/__init__.py +++ b/mo/web/__init__.py @@ -115,6 +115,7 @@ app.assets.add_assets([ 'bootstrap.min.css', 'mo.css', 'js/news-reloader.js', + 'js/osmo.js', ]) @@ -141,7 +142,7 @@ def init_request(): if not user: # Uživatel mezitím přestal existovat app.logger.error('Zrušena session pro neexistujícího uživatele uid=%s', session['uid']) - return mo.web.auth.logout() + return mo.web.acct.logout() else: user = None @@ -171,18 +172,28 @@ app.before_request(init_request) ### UWSGI glue ### +# Čas od času se probudíme a spustíme garbage collector: +# - projdeme joby pro případ, že by se ztratil signál +# - expirujeme zastaralé joby +# - expirujeme zastaralé registrační tokeny +@app.cli.command('gc') +def gc(): + """Run garbage collector.""" + + mo.now = mo.util.get_now() + mo.jobs.process_jobs() + mo.users.expire_reg_requests() + + try: import uwsgi from uwsgidecorators import timer, signal - # Čas od času se probudíme a projdeme joby pro případ, že by se ztratil signál. - # Také při tom expirujeme zastaralé joby. - @timer(config.JOB_GC_PERIOD, target='mule') + @timer(config.GC_PERIOD, target='mule') def mule_timer(signum): # app.logger.debug('Mule: Timer tick') with app.app_context(): - mo.now = mo.util.get_now() - mo.jobs.process_jobs() + garbage_collect() # Obykle při vložení jobu dostaneme signál. @signal(42, target='mule') @@ -205,7 +216,8 @@ except ImportError: # Většina webu je v samostatných modulech -import mo.web.auth +import mo.web.api +import mo.web.acct import mo.web.jinja import mo.web.menu import mo.web.misc diff --git a/mo/web/acct.py b/mo/web/acct.py new file mode 100644 index 0000000000000000000000000000000000000000..cd3bc74e15d377bc877887ec678a6f6a2fbcab9d --- /dev/null +++ b/mo/web/acct.py @@ -0,0 +1,629 @@ +import datetime +import dateutil.tz +from enum import Enum, auto +from flask import render_template, request, g, redirect, url_for, session +from flask.helpers import flash +from flask_wtf import FlaskForm +import html +from markupsafe import Markup +import random +import secrets +from sqlalchemy.orm import joinedload +from typing import Optional, Dict +import werkzeug.exceptions +import wtforms +from wtforms import validators, ValidationError +from wtforms.fields.html5 import EmailField + +import mo.config as config +import mo.db as db +import mo.rights +import mo.tokens +import mo.users +import mo.util +from mo.web import app, NeedLoginError +import mo.web.fields as mo_fields + + +class LoginForm(FlaskForm): + next = wtforms.HiddenField() + email = mo_fields.Email(validators=[validators.DataRequired()]) + passwd = wtforms.PasswordField('Heslo') + submit = wtforms.SubmitField('Přihlásit se') + reset = wtforms.SubmitField('Zapomenuté heslo') + + +def login_and_redirect(user: db.User, flash_msg: Optional[str] = None, url: Optional[str] = None): + session.clear() + session['uid'] = user.user_id + if not url: + if user.is_admin or user.is_org: + url = url_for('org_index') + else: + url = url_for('index') + else: + url = request.script_root + url + if flash_msg: + flash(flash_msg, 'success') + return redirect(url) + + +@app.route('/acct/login', methods=('GET', 'POST')) +def login(): + form = LoginForm(email=request.args.get('email')) + + if not form.validate_on_submit(): + return render_template('login.html', form=form, error=None) + + email = form.email.data + user = mo.users.user_by_email(email) + + if not user: + app.logger.error('Login: Neznámý uživatel <%s>', email) + flash('Neznámý uživatel', 'danger') + elif form.reset.data: + rr = mo.users.request_reset_password(user, request.remote_addr) + if rr: + db.get_session().commit() + mo.email.send_password_reset_email(user, rr.email_token) + flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla.', 'success') + else: + flash('Příliš časté požadavky na obnovu hesla.', 'danger') + elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data): + app.logger.error('Login: Špatné heslo pro uživatele <%s>', email) + flash('Chybné heslo', 'danger') + else: + if user.is_admin: + typ = ' (admin)' + elif user.is_org: + typ = ' (org)' + elif user.is_test: + typ = ' (test)' + else: + typ = "" + app.logger.info('Login: Přihlásil se uživatel #%s <%s>%s', user.user_id, email, typ) + mo.users.login(user) + db.get_session().commit() + return login_and_redirect(user, url=form.next.data) + + return render_template('login.html', form=form) + + +@app.route('/acct/logout', methods=('POST',)) +def logout(): + session.clear() + return redirect(url_for('index')) + + +@app.route('/acct/incarnate/<int:id>', methods=('POST',)) +def incarnate(id): + if not g.user.is_admin: + raise werkzeug.exceptions.Forbidden() + + new_user = db.get_session().query(db.User).get(id) + if not new_user: + raise werkzeug.exceptions.NotFound() + + app.logger.info('Login: Uživatel #%s se převtělil na #%s', g.user.user_id, new_user.user_id) + return login_and_redirect(new_user, flash_msg='Převtělení proběhlo') + + +@app.route('/user/settings') +def user_settings(): + sess = db.get_session() + + roles = (sess.query(db.UserRole) + .filter_by(user_id=g.user.user_id) + .options(joinedload(db.UserRole.place)) + .all()) + + 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)) + + return render_template('settings.html', user=g.user, pant=pant, roles=roles, roles_by_type=mo.rights.roles_by_type) + + +class SettingsForm(FlaskForm): + email = mo_fields.Email(validators=[validators.DataRequired()]) + current_passwd = wtforms.PasswordField('Aktuální heslo', validators=[validators.DataRequired()]) + new_passwd = mo_fields.NewPassword( + description=mo.users.password_help + ' Pokud nechcete heslo měnit, ponechte toto políčko prázdné.', + ) + new_passwd2 = mo_fields.RepeatPassword() + submit = wtforms.SubmitField('Nastavit') + + def validate_current_passwd(form, field): + if not mo.users.check_password(g.user, field.data): + raise ValidationError('Chybné heslo.') + + +@app.route('/user/settings/change', methods=('GET', 'POST')) +def user_settings_change(): + sess = db.get_session() + user = g.user + + form = SettingsForm() + if not form.submit.data: + form.email.data = user.email + + if form.validate_on_submit(): + ok = True + if form.new_passwd.data: + app.logger.info(f'Settings: Změněno heslo uživatele #{user.user_id}') + mo.users.set_password(user, form.new_passwd.data) + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={'action': 'change-passwd'}, + ) + sess.commit() + flash('Heslo změněno.', 'success') + if form.email.data != user.email: + rr = mo.users.new_reg_request(db.RegReqType.change_email, request.remote_addr) + if rr: + rr.user_id = user.user_id + rr.email = form.email.data + sess.add(rr) + sess.commit() + app.logger.info(f'Settings: Požadavek na změnu e-mailu uživatele #{user.user_id}') + flash('Odeslán e-mail s odkazem na potvrzení nové adresy.', 'success') + mo.email.send_confirm_change_email(user, rr.email_token) + else: + app.logger.info('Settings: Rate limit') + flash('Příliš mnoho požadavků na změny e-mailu. Počkejte prosím chvíli a zkuste to znovu.', 'danger') + ok = False + if ok: + return redirect(url_for('user_settings')) + + return render_template('settings_change.html', form=form) + + +@app.errorhandler(NeedLoginError) +def handle_need_login(e): + form = LoginForm() + form.next.data = request.path + return render_template('login.html', form=form), e.code + + +class ResetForm(FlaskForm): + email = wtforms.StringField('E-mail', description='Účet pro který se nastavuje nové heslo', render_kw={"disabled": "disabled"}) + new_passwd = mo_fields.NewPassword(validators=[validators.DataRequired()]) + new_passwd2 = mo_fields.RepeatPassword(validators=[validators.DataRequired()]) + submit = wtforms.SubmitField('Nastavit heslo') + + +# URL je explicitně uvedeno v mo.email.activate_url +@app.route('/acct/activate', methods=('GET', 'POST')) +def activate(): + token = request.args.get('token') + if not token: + flash('Chybí token pro aktivaci účtu', 'danger') + return redirect(url_for('login')) + + user = mo.users.check_activation_token(token) + if not user: + flash('Neplatný kód pro aktivaci účtu. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', 'danger') + return redirect(url_for('login')) + + if user.last_login_at is not None: + flash('Tento účet už byl aktivován. Pokud neznáte heslo, použijte tlačítko pro obnovu hesla.', 'danger') + return redirect(url_for('login')) + + form = ResetForm(email=user.email) + ok = form.validate_on_submit() + if not ok: + return render_template('acct_activate.html', form=form) + + app.logger.info('Login: Aktivace účtu uživatele <%s>', user.email) + mo.users.set_password(user, form.new_passwd.data, reset=True) + mo.users.login(user) + db.get_session().commit() + return login_and_redirect(user, flash_msg='Nastavení nového hesla a přihlášení do systému proběhlo úspěšně') + + +class RegStatus(Enum): + new = auto() + ok = auto() + expired = auto() + already_exists = auto() + # Jen v 1. kroku: + rate_limited = auto() + wrong_captcha = auto() + # Jen v 2. kroku: + already_spent = auto() + + +class Reg1: + create_time: datetime.datetime + seed: str + status: RegStatus + email_token: str + x: int + y: int + + def __init__(self, from_token: Optional[str] = None): + self.status = self._parse_token(from_token) + if self.status == RegStatus.ok: + self._gen_captcha() + else: + self._reset() + + def _reset(self): + self.create_time = mo.now + self.seed = secrets.token_hex(16) + app.logger.debug(f'Reg1: Nový token: seed={self.seed}') + self._gen_captcha() + + def as_token(self) -> str: + return mo.tokens.sign_token([str(int(self.create_time.timestamp())), self.seed], 'reg1') + + def _parse_token(self, token: Optional[str]) -> RegStatus: + if token is None: + return RegStatus.new + + fields = mo.tokens.verify_token(token, 'reg1') + if not fields: + app.logger.debug(f'Reg1: Neplatný token: {token}') + return RegStatus.new + + self.create_time = datetime.datetime.fromtimestamp(int(fields[0]), tz=dateutil.tz.UTC) + self.seed = fields[1] + app.logger.debug(f'Reg1: Přijat token: {self.create_time} {self.seed}') + + token_age = mo.now - self.create_time + if token_age > datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY): + app.logger.debug('Reg1: Token expiroval') + return RegStatus.expired + + return RegStatus.ok + + def _init_rng(self) -> random.Random: + rng = random.Random() + rng.seed(mo.tokens.hash('rng-init', self.seed)) + return rng + + def _gen_captcha(self): + rng = self._init_rng() + self.x = rng.randrange(1, 10) + self.y = rng.randrange(1, 10) + app.logger.debug(f'Reg1: Captcha: {self.x}*{self.y}') + + def captcha_task(self) -> str: + cisla = ['nula', 'jedna', 'dva', 'tři', 'čtyři', 'pět', 'šest', 'sedm', 'osm', 'devět', + 'deset', 'jedenáct', 'dvanáct', 'třináct', 'čtrnáct', 'patnáct', 'šestnáct', 'sedmnáct', 'osmnáct', 'devatenáct'] + return f'Napište číslem, kolik je {cisla[self.x]} krát {cisla[self.y]}.' + + def captcha_check_answer(self, answer: str) -> bool: + correct = self.x * self.y + if answer == str(correct): + app.logger.debug(f'Reg1: Captcha: {self.x}*{self.y}={answer} správně') + return True + else: + app.logger.debug(f'Reg1: Captcha: {self.x}*{self.y}={answer} špatně') + return False + + def create_reg_request(self, email: str) -> bool: + sess = db.get_session() + rr = sess.query(db.RegRequest).with_for_update().filter_by(captcha_token=self.seed).one_or_none() + if rr: + self._reset() + self.status = RegStatus.expired + sess.rollback() + app.logger.info('Reg1: Captcha token použit znovu') + return False + + rr = mo.users.new_reg_request(db.RegReqType.register, request.remote_addr) + if not rr: + self._reset() + self.status = RegStatus.rate_limited + sess.rollback() + app.logger.info('Reg1: Rate limit') + return False + + self.email_token = rr.email_token + rr.email = email + rr.captcha_token = self.seed + sess.add(rr) + sess.commit() + return True + + def process(self, email: str, captcha: str) -> bool: + # XXX: Nejdříve zapisujeme registraci do DB, a teprve pak ověřujeme captchu. + # Tímto způsobem je těžší captchu obejít (protože je rate-limitovaná), ale + # zase je snazší páchat DoS útok na celou registraci (protože je rate-limitovaná). + + if not self.create_reg_request(email): + return False + + if not self.captcha_check_answer(captcha): + self._reset() + self.status = RegStatus.wrong_captcha + return False + + if mo.users.user_by_email(email): + self._reset() + self.status = RegStatus.already_exists + app.logger.info(f'Reg1: Účet s e-mailem {email} už existuje') + return False + + return True + + +class Reg1Form(FlaskForm): + email = mo_fields.Email(validators=[validators.DataRequired()]) + token = wtforms.HiddenField() + captcha = wtforms.StringField('Kontrolní odpověď', validators=[validators.DataRequired()]) + submit = wtforms.SubmitField('Vytvořit účet') + + +@app.route('/acct/create', methods=('GET', 'POST')) +def create_acct(): + form = Reg1Form() + reg1 = Reg1(form.token.data) + + if reg1.status == RegStatus.ok and form.validate_on_submit() and reg1.process(form.email.data, form.captcha.data): + app.logger.debug(f'Reg1: E-mailový token {reg1.email_token}') + flash('Odeslán e-mail s odkazem na založení účtu.', 'success') + user = db.User(email=form.email.data, first_name='Nový', last_name='Uživatel') + mo.email.send_confirm_create_email(user, reg1.email_token) + return redirect(url_for('confirm_reg')) + + form.captcha.description = reg1.captcha_task() + if reg1.status != RegStatus.ok: + form.token.data = reg1.as_token() + form.captcha.data = "" + + if reg1.status == RegStatus.expired: + flash('Vypršela platnost formuláře, vyplňte ho prosím znovu.', 'danger') + elif reg1.status == RegStatus.rate_limited: + flash('Přichází příliš mnoho registrací najednou, zkuste to prosím za chvíli znovu.', 'danger') + elif reg1.status == RegStatus.already_exists: + form.email.errors.append('Účet s touto adresou už existuje. ' + Markup('<a href="' + html.escape(url_for('login', email=form.email.data)) + '">Přihlásit se.</a>')) + elif reg1.status == RegStatus.wrong_captcha: + form.captcha.errors.append('Chybný výsledek. Zkuste to znovu: ' + reg1.captcha_task()) + + return render_template('acct_reg1.html', form=form) + + +class Reg2: + reg_type: db.RegReqType + status: RegStatus + rr: db.RegRequest + user: db.User + + messages: Dict[db.RegReqType, Dict[RegStatus, str]] = { + db.RegReqType.register: { + RegStatus.new: 'Chybný registrační kód. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', + RegStatus.expired: 'Vypršela platnost registrace, vyplňte ji prosím znovu.', + RegStatus.already_spent: 'Tento odkaz na potvrzení registrace byl již využit.', + RegStatus.already_exists: 'Účet s touto adresou už existuje.', + }, + db.RegReqType.change_email: { + RegStatus.new: 'Chybný potvrzovací kód. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', + RegStatus.expired: 'Vypršela platnost potvrzovacího kódu, požádejte prosím o změnu e-mailu znovu.', + RegStatus.already_spent: 'Tento odkaz na potvrzení změny e-mailu byl již využit.', + }, + db.RegReqType.reset_passwd: { + RegStatus.new: 'Chybný kód pro obnovení hesla. Zkontrolujte, že jste odkaz z e-mailu zkopírovali správně.', + RegStatus.expired: 'Vypršela platnost kódu pro obnovení hesla, požádejte prosím o obnovu znovu.', + RegStatus.already_spent: 'Tento odkaz na obnovení hesla byl již využit.', + }, + } + + def __init__(self, token: str, expected_type: db.RegReqType): + self.reg_type = expected_type + self.status = self._parse_token(token) + if self.status == RegStatus.ok: + self.status = self._load_rr(token) + + def _parse_token(self, token: Optional[str]) -> RegStatus: + if not token: + return RegStatus.new + + token = mo.util.clean_up_token(token) + fields = mo.tokens.verify_token(token, 'reg-request') + if not fields: + app.logger.debug(f'Reg2: Neplatný token: {token}') + return RegStatus.new + + create_time = datetime.datetime.fromtimestamp(int(fields[0]), tz=dateutil.tz.UTC) + app.logger.debug(f'Reg2: Přijat token: {token}') + + token_age = mo.now - create_time + if token_age > datetime.timedelta(minutes=config.REG_TOKEN_VALIDITY): + app.logger.debug('Reg2: Token expiroval') + return RegStatus.expired + + return RegStatus.ok + + def _load_rr(self, token: str) -> RegStatus: + sess = db.get_session() + rr = sess.query(db.RegRequest).with_for_update().filter_by(email_token=token).one_or_none() + if not rr: + app.logger.info('Reg2: Registrace nenalezena') + return RegStatus.expired + + if rr.expires_at < mo.now: + app.logger.info('Reg2: Registrace expirovala') + return RegStatus.expired + + if rr.used_at is not None: + app.logger.info('Reg2: Registrace spotřebována') + return RegStatus.already_spent + + if rr.type != self.reg_type: + app.logger.info('Reg2: Token špatného typu') + return RegStatus.new + + self.rr = rr + return RegStatus.ok + + def create(self, first_name: str, last_name: str, passwd: str) -> bool: + rr = self.rr + assert rr.email is not None + email = mo.users.normalize_email(rr.email) # Pro jistotu + sess = db.get_session() + + if db.get_session().query(db.User).with_for_update().filter_by(email=email).one_or_none(): + # Účet mohl začít existovat mezi 1. a 2. krokem registrace + app.logger.info(f'Reg2: Účet s e-mailem {email} začal během registrace existovat') + self.status = RegStatus.already_exists + return False + + user = db.User( + email=email, + first_name=first_name, + last_name=last_name, + ) + mo.users.set_password(user, passwd) + + rr.used_at = mo.now + sess.add(user) + sess.flush() + + app.logger.info(f'Reg2: Založen uživatel user=#{user.user_id} email=<{user.email}>') + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={'action': 'register', 'new': db.row2dict(user)}, + ) + + sess.commit() + self.user = user + return True + + def change_email(self): + sess = db.get_session() + user = g.user + user.email = self.rr.email + + app.logger.info(f'Reg2: Uživatel #{user.user_id} si změnil email na <{user.email}>') + mo.util.log( + type=db.LogType.user, + what=user.user_id, + details={ + 'action': 'change-settings', + 'changes': db.get_object_changes(user), + }, + ) + + self.rr.used_at = mo.now + sess.commit() + + def change_passwd(self, new_passwd: str): + sess = db.get_session() + user = self.rr.user + + app.logger.info(f'Reg2: Uživatel #{user.user_id} si resetoval heslo') + mo.users.set_password(user, new_passwd, reset=True) + mo.users.login(user) + + self.rr.used_at = mo.now + sess.commit() + + def spend_request(self): + self.rr.used_at = mo.now + db.get_session().commit() + + def flash_message(self): + msgs = self.messages[self.reg_type] + if self.status in msgs: + flash(msgs[self.status], 'danger') + + +class Reg2Form(FlaskForm): + email = wtforms.StringField('E-mail', render_kw={"disabled": "disabled"}) + first_name = mo_fields.FirstName(validators=[validators.DataRequired()]) + last_name = mo_fields.LastName(validators=[validators.DataRequired()]) + new_passwd = mo_fields.NewPassword('Heslo', validators=[validators.DataRequired()]) + new_passwd2 = mo_fields.RepeatPassword(validators=[validators.DataRequired()]) + submit = wtforms.SubmitField('Vytvořit účet') + + +# URL je explicitně uvedeno v mo.email.activate_url +@app.route('/acct/confirm/r', methods=('GET', 'POST')) +def confirm_reg(): + token = request.args.get('token') + if token is None: + return render_template('acct_reg2.html', form=None) + + reg2 = Reg2(token, db.RegReqType.register) + if reg2.status != RegStatus.ok: + reg2.flash_message() + return redirect(url_for('create_acct')) + + form = Reg2Form() + if form.validate_on_submit(): + if reg2.create(form.first_name.data, form.last_name.data, form.new_passwd.data): + flash('Založení účtu a přihlášení do systému proběhlo úspěšně.', 'success') + app.logger.info(f'Login: Přihlásil se uživatel <{reg2.user.email}> po založení účtu') + return login_and_redirect(reg2.user, flash_msg='Účet úspěšně založen.') + + reg2.flash_message() + form.email.data = reg2.rr.email + + return render_template('acct_reg2.html', form=form) + + +class ConfirmEmailForm(FlaskForm): + orig_email = wtforms.StringField('Původní e-mail', render_kw={"disabled": "disabled"}) + new_email = wtforms.StringField('Nový e-mail', render_kw={"disabled": "disabled"}) + submit = wtforms.SubmitField('Potvrdit změnu') + cancel = wtforms.SubmitField('Zrušit požadavek') + + +# URL je explicitně uvedeno v mo.email.activate_url +@app.route('/acct/confirm/e', methods=('GET', 'POST')) +def confirm_email(): + reg2 = Reg2(request.args.get('token'), db.RegReqType.change_email) + if reg2.status != RegStatus.ok: + reg2.flash_message() + return redirect(url_for('user_settings')) + + form = ConfirmEmailForm() + if form.validate_on_submit(): + if form.submit.data: + reg2.change_email() + flash('E-mail změněn.', 'success') + elif form.cancel.data: + reg2.spend_request() + flash('Požadavek na změnu e-mailu zrušen.', 'success') + return redirect(url_for('user_settings')) + + form.orig_email.data = g.user.email + form.new_email.data = reg2.rr.email + + return render_template('acct_confirm_email.html', form=form) + + +class CancelResetForm(FlaskForm): + cancel = wtforms.SubmitField('Zrušit obnovu hesla') + + +# URL je explicitně uvedeno v mo.email.activate_url +@app.route('/acct/confirm/p', methods=('GET', 'POST')) +def confirm_reset(): + reg2 = Reg2(request.args.get('token'), db.RegReqType.reset_passwd) + if reg2.status != RegStatus.ok: + reg2.flash_message() + return redirect(url_for('login')) + + form = ResetForm(email=reg2.rr.user.email) + if form.validate_on_submit() and form.submit.data: + reg2.change_passwd(form.new_passwd.data) + return login_and_redirect(reg2.rr.user, flash_msg='Nastavení nového hesla a přihlášení do systému proběhlo úspěšně') + + cform = CancelResetForm() + if cform.validate_on_submit() and cform.cancel.data: + reg2.spend_request() + flash('Požadavek na změnu hesla zrušen.', 'success') + return redirect(url_for('user_settings')) + + return render_template('acct_reset_passwd.html', form=form, cancel_form=cform) + + +@app.errorhandler(werkzeug.exceptions.Forbidden) +def handle_forbidden(e): + return render_template('forbidden.html') diff --git a/mo/web/api.py b/mo/web/api.py new file mode 100644 index 0000000000000000000000000000000000000000..b6ba68baabdc576c2a0cfb71d6ccd0c2f9495192 --- /dev/null +++ b/mo/web/api.py @@ -0,0 +1,77 @@ +from flask import request +from flask.json import jsonify +from sqlalchemy import func +from sqlalchemy.orm import joinedload +import werkzeug.exceptions + +import mo.db as db +from mo.util_format import inflect_with_number +from mo.web import app + + +@app.route('/api/') +def api_root(): + """Slouží jako prefix pro konstrukci URL v JavaScriptu.""" + raise werkzeug.exceptions.NotFound() + + +@app.route('/api/find-town') +def api_find_town(): + query = request.args.get('q') + if query is None or len(query) < 2: + return jsonify(error='Zadejte alespoň 2 znaky jména obce.') + elif '%' in query: + return jsonify(error='Nepovolené znaky ve jménu obce.') + else: + max_places = 50 + places = (db.get_session().query(db.Place) + .filter_by(level=3) + .filter(func.lower(db.f_unaccent(db.Place.name)).like(func.lower(db.f_unaccent(query + '%')))) + .options(joinedload(db.Place.parent_place)) + .order_by(db.Place.name, db.Place.place_id) + .limit(max_places) + .all()) + if not places: + return jsonify(error='Nenalezena žádná obec.') + # XXX: Nemůže se stát, že nastane přesná shoda a k tomu příliš mnoho nepřesných? + if len(places) >= max_places: + return jsonify(error='Nalezeno příliš mnoho obcí. Zadejte prosím více znaků jména.') + + res = [] + for p in places: + name = p.name + if p.name != p.parent_place.name: + name += f' (okres {p.parent_place.name})' + res.append([p.place_id, name]) + + msg = inflect_with_number(len(res), 'Nalezena %s obec.', 'Nalezeny %s obce.', 'Nalezeno %s obcí.') + + return jsonify(found=res, msg=msg) + + +@app.route('/api/get-schools') +def api_get_schools(): + town = request.args.get('town') + if town is None or not town.isnumeric(): + raise werkzeug.exceptions.BadRequest() + town_id = int(town) + + places = (db.get_session().query(db.Place) + .filter_by(level=4, type=db.PlaceType.school, parent=town_id) + .options(joinedload(db.Place.school)) + .order_by(db.Place.name) + .all()) + + zs = [] + ss = [] + for p in places: + s = { + 'id': p.place_id, + 'name': p.name, + } + if p.school.is_zs: + zs.append(s) + if p.school.is_ss: + ss.append(s) + + return jsonify(zs=zs, ss=ss) diff --git a/mo/web/auth.py b/mo/web/auth.py deleted file mode 100644 index 1185c38e60bb1fafedd20353f55e035972a2434e..0000000000000000000000000000000000000000 --- a/mo/web/auth.py +++ /dev/null @@ -1,179 +0,0 @@ -import datetime - -from flask import render_template, request, g, redirect, url_for, session -from flask.helpers import flash -from flask_wtf import FlaskForm -import werkzeug.exceptions -import wtforms -from wtforms.fields.html5 import EmailField -import wtforms.validators as validators -from sqlalchemy.orm import joinedload -from typing import Optional - -import mo.util -import mo.db as db -import mo.rights -import mo.users -from mo.web import app, NeedLoginError - - -class LoginForm(FlaskForm): - next = wtforms.HiddenField() - email = EmailField('E-mail', validators=[validators.DataRequired()]) - passwd = wtforms.PasswordField('Heslo') - submit = wtforms.SubmitField('Přihlásit se') - reset = wtforms.SubmitField('Zapomenuté heslo') - - -def login_and_redirect(user: db.User, url: Optional[str] = None): - session.clear() - session['uid'] = user.user_id - if not url: - if user.is_admin or user.is_org: - url = url_for('org_index') - else: - url = url_for('index') - else: - url = request.script_root + url - return redirect(url) - - -@app.route('/auth/login', methods=('GET', 'POST')) -def login(): - form = LoginForm(email=request.args.get('email')) - - if not form.validate_on_submit(): - return render_template('login.html', form=form, error=None) - - email = form.email.data - user = mo.users.user_by_email(email) - - if not user: - app.logger.error('Login: Neznámý uživatel <%s>', email) - flash('Neznámý uživatel', 'danger') - elif form.reset.data: - app.logger.info('Login: Požadavek na reset hesla pro <%s>', email) - - min_time_between_resets = datetime.timedelta(minutes=1) - now = datetime.datetime.now().astimezone() - if (user.reset_at is not None - and now - user.reset_at < min_time_between_resets): - flash('Poslední požadavek na obnovení hesla byl odeslán příliš nedávno', 'danger') - else: - token = mo.users.ask_reset_password(user) - db.get_session().commit() - - mo.util.send_password_reset_email(user, token) - flash('Na uvedenou adresu byl odeslán e-mail s odkazem na obnovu hesla', 'success') - - elif not form.passwd.data or not mo.users.check_password(user, form.passwd.data): - app.logger.error('Login: Špatné heslo pro uživatele <%s>', email) - flash('Chybné heslo', 'danger') - else: - if user.is_admin: - typ = ' (admin)' - elif user.is_org: - typ = ' (org)' - elif user.is_test: - typ = ' (test)' - else: - typ = "" - app.logger.info('Login: Přihlásil se uživatel #%s <%s>%s', user.user_id, email, typ) - mo.users.login(user) - db.get_session().commit() - return login_and_redirect(user, url=form.next.data) - - return render_template('login.html', form=form) - - -@app.route('/auth/logout', methods=('POST',)) -def logout(): - session.clear() - return redirect(url_for('index')) - - -@app.route('/auth/incarnate/<int:id>', methods=('POST',)) -def incarnate(id): - if not g.user.is_admin: - raise werkzeug.exceptions.Forbidden() - - new_user = db.get_session().query(db.User).get(id) - if not new_user: - raise werkzeug.exceptions.NotFound() - - app.logger.info('Login: Uživatel #%s se převtělil na #%s', g.user.user_id, new_user.user_id) - return login_and_redirect(new_user) - - -@app.route('/user/settings') -def user_settings(): - sess = db.get_session() - roles = [] - if g.user: - roles = (sess.query(db.UserRole) - .filter_by(user_id=g.user.user_id) - .options(joinedload(db.UserRole.place)) - .all()) - return render_template('settings.html', roles=roles, roles_by_type=mo.rights.roles_by_type) - - -@app.errorhandler(NeedLoginError) -def handle_need_login(e): - form = LoginForm() - form.next.data = request.path - return render_template('login.html', form=form), e.code - - -class ResetForm(FlaskForm): - email = EmailField('E-mail', description='Účet pro který se nastavuje nové heslo', render_kw={"disabled": "disabled"}) - token = wtforms.HiddenField() - passwd = wtforms.PasswordField('Nové heslo', description='Heslo musí mít alespoň 8 znaků. Doporučujeme kombinovat velká a malá písmena a číslice.') - submit = wtforms.SubmitField('Nastavit heslo') - cancel = wtforms.SubmitField('Zrušit obnovu hesla') - - -@app.route('/auth/reset', methods=('GET', 'POST')) -def reset(): - token = request.args.get('token') - if not token: - flash('Žádný token pro resetování hesla', 'danger') - return redirect(url_for('login')) - - user = mo.users.check_reset_password(token) - if not user: - flash('Neplatný požadavek na obnovu hesla', 'danger') - return redirect(url_for('login')) - - form = ResetForm(token=token, email=user.email) - ok = form.validate_on_submit() - if not ok: - return render_template('reset.html', form=form) - - if form.cancel.data: - mo.users.cancel_reset_password(user) - app.logger.info('Login: Zrušen reset hesla pro uživatele <%s>', user.email) - db.get_session().commit() - flash('Obnova hesla zrušena', 'warning') - return redirect(url_for('login')) - elif len(form.passwd.data) < 8: - flash('Heslo musí být aspoň 8 znaků dlouhé', 'danger') - return render_template('reset.html', form=form) - else: - mo.users.do_reset_password(user) - mo.users.set_password(user, form.passwd.data) - app.logger.info('Login: Reset hesla pro uživatele <%s>', user.email) - mo.util.log( - type=db.LogType.user, - what=user.user_id, - details={'action': 'reset-passwd'}, - ) - mo.users.login(user) - app.logger.info('Login: Přihlásil se uživatel <%s> po resetování hesla', user.email) - db.get_session().commit() - flash('Nastavení nového hesla a přihlášení do systému proběhlo úspěšně', 'success') - return login_and_redirect(user) - - -@app.errorhandler(werkzeug.exceptions.Forbidden) -def handle_forbidden(e): - return render_template('forbidden.html') diff --git a/mo/web/fields.py b/mo/web/fields.py index e6f21da098528f367c161d9421dc366ee78b8135..1a931335a4e06fcc40cb3c985c5c99a32caecf68 100644 --- a/mo/web/fields.py +++ b/mo/web/fields.py @@ -133,8 +133,30 @@ class School(Place): def load_place(field) -> None: field.place = None + field.place_error = "" if field.data: try: field.place = mo.users.validate_and_find_school(field.data) except mo.CheckError as e: field.place_error = str(e) + + +class NewPassword(wtforms.PasswordField): + def __init__(self, label="Nové heslo", validators=None, **kwargs): + super().__init__(label, validators, **kwargs) + + def pre_validate(field, form): + if field.data: + if not mo.users.validate_password(field.data): + raise wtforms.ValidationError(mo.users.password_help) + + +class RepeatPassword(wtforms.PasswordField): + """Pro validaci hledá ve formuláři form.new_passwd a s ním porovnává.""" + + def __init__(self, label="Zopakujte heslo", validators=None, **kwargs): + super().__init__(label, validators, **kwargs) + + def pre_validate(field, form): + if field.data != form.new_passwd.data: + raise wtforms.ValidationError('Hesla se neshodují.') diff --git a/mo/web/jinja.py b/mo/web/jinja.py index 503283165a419441381f12753fa4137640c72ae4..c81543bad49d165a1fd15c3582881a363bd5fd18 100644 --- a/mo/web/jinja.py +++ b/mo/web/jinja.py @@ -24,6 +24,7 @@ app.jinja_env.trim_blocks = True # Filtry definované v mo.util_format app.jinja_env.filters.update(timeformat=util_format.timeformat) +app.jinja_env.filters.update(timeformat_short=util_format.timeformat_short) app.jinja_env.filters.update(inflected=util_format.inflect_number) app.jinja_env.filters.update(inflected_by=util_format.inflect_by_number) app.jinja_env.filters.update(timedelta=util_format.timedelta) @@ -93,6 +94,11 @@ def yes_no(a: bool) -> str: return "ano" if a else "ne" +@app.template_filter() +def jsescape(js: Any) -> str: + return Markup(json_pretty(js)) + + @app.template_filter() def json_pretty(js: Any) -> str: return json.dumps(js, sort_keys=True, indent=4, ensure_ascii=False) diff --git a/mo/web/menu.py b/mo/web/menu.py index f9b08f4fbb0cecb18177bc9589a294245d4249fc..d4760913e2b797f5a73d2b41d6208a79f7d9fe86 100644 --- a/mo/web/menu.py +++ b/mo/web/menu.py @@ -43,7 +43,8 @@ def get_menu(): name += " [admin]" items.append(MenuItem(url_for('user_settings'), name, classes=["right"])) else: - items.append(MenuItem(url_for('login'), "Přihlásit se", active_prefix="/auth/", classes=["right"])) + items.append(MenuItem(url_for('create_acct'), "Založit účet", classes=["right"])) + items.append(MenuItem(url_for('login'), "Přihlásit se", active_prefix="/acct/", classes=["right"])) active = None for item in items: diff --git a/mo/web/org.py b/mo/web/org.py index 72c67429e74bc812683b708653fb918e54036d56..78d5c77c8a22783b8dbadbc8186ee52a8e4bea11 100644 --- a/mo/web/org.py +++ b/mo/web/org.py @@ -78,8 +78,8 @@ school_export_columns = ( ) -@app.route('/org/export/skoly') -def org_export_skoly(): +@app.route('/org/export/schools') +def org_export_shools(): sess = db.get_session() format = request.args.get('format', 'en_csv') diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index 7e6c9e37c5eba5d50e1edd75c2769905999af834..3eb31e2eecdf393c9657e25483005fb6022f7fe7 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -442,10 +442,11 @@ def org_contest_import(id: int): ) -@app.route('/org/contest/c/<int:id>/ucastnici', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:id>/site/<int:site_id>/ucastnici', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:id>/ucastnici/emails', endpoint="org_contest_list_emails") -@app.route('/org/contest/c/<int:id>/site/<int:site_id>/ucastnici/emails', endpoint="org_contest_list_emails") +# 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' @@ -1497,7 +1498,7 @@ def org_contest_advance(contest_id: int): prev_pion_query = (sess.query(db.Participation) .filter(db.Participation.contest_id.in_([c.contest_id for c in prev_contests])) - .filter(db.Participation.state.in_((db.PartState.registered, db.PartState.invited, db.PartState.present)))) + .filter_by(state=db.PartState.active)) prev_pions = prev_pion_query.all() if form.boundary.data > 0: @@ -1540,7 +1541,7 @@ def org_contest_advance(contest_id: int): user_id=pp.user_id, contest_id=contest.contest_id, place_id=contest.place.place_id, - state=db.PartState.invited, + state=db.PartState.active, ) .on_conflict_do_nothing() .returning(db.Participation.contest_id) @@ -1650,8 +1651,8 @@ 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>/ucastnici/pridat', methods=('GET', 'POST')) -@app.route('/org/contest/c/<int:id>/site/<int:site_id>/ucastnici/pridat', methods=('GET', 'POST')) +@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) @@ -1674,6 +1675,8 @@ def org_contest_add_user(id: int, site_id: Optional[int] = None): db.get_session().commit() if is_new_user: flash("Založen nový uživatel.", "info") + token = mo.users.make_activation_token(user) + mo.email.send_new_account_email(user, token) if is_new_participant: flash("Založena nová registrace do ročníku.", "info") if is_new_participation: diff --git a/mo/web/org_round.py b/mo/web/org_round.py index b4da3657181f26e13febaa369f5fb1c09b88133b..f70de3839bd7929168f8134bfd101d1c0e9b7739 100644 --- a/mo/web/org_round.py +++ b/mo/web/org_round.py @@ -55,7 +55,15 @@ 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) - return render_template('org_rounds.html', rounds=rounds) + return render_template('org_rounds.html', rounds=rounds, history=False) + + +@app.route('/org/contest/history') +def org_rounds_history(): + sess = db.get_session() + + rounds = sess.query(db.Round).order_by(db.Round.year.desc(), db.Round.category, db.Round.seq, db.Round.part) + return render_template('org_rounds.html', rounds=rounds, history=True) class TaskDeleteForm(FlaskForm): @@ -144,14 +152,26 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool: ) app.logger.info(f"Soutěž #{contest.contest_id} založena: {db.row2dict(contest)}") - # Přidání soutěže do podkol ve skupině - subrounds = round.master.get_group_rounds() + create_subcontests(round.master, contest) + + sess.commit() + flash(f'Založena soutěž {place.name_locative()}', 'success') + return True + + +# XXX: Používá se i v registraci účastníků +def create_subcontests(master_round: db.Round, master_contest: db.Contest): + if master_round.part == 0: + return + + sess = db.get_session() + subrounds = master_round.get_group_rounds() for subround in subrounds: subcontest = db.Contest( round_id=subround.round_id, - master_contest_id=contest.contest_id, - place_id=contest.place_id, - state=state, + master_contest_id=master_contest.contest_id, + place_id=master_contest.place_id, + state=master_contest.state, ) sess.add(subcontest) sess.flush() @@ -161,11 +181,7 @@ def add_contest(round: db.Round, form: AddContestForm) -> bool: what=subcontest.contest_id, details={'action': 'add', 'contest': db.row2dict(subcontest)}, ) - app.logger.info(f"Soutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}") - - sess.commit() - flash(f'Založena soutěž {place.name_locative()}', 'success') - return True + app.logger.info(f"Podsoutěž #{subcontest.contest_id} založena: {db.row2dict(subcontest)}") @app.route('/org/contest/r/<int:id>/', methods=('GET', 'POST')) @@ -446,6 +462,8 @@ class RoundEditForm(FlaskForm): "Přesnost bodování", choices=db.round_points_step_choices, coerce=decimal.Decimal, description="Ovlivňuje možnost zadávání nových bodů, již uložené body nezmění" ) + enroll_mode = wtforms.SelectField("Režim přihlašování", choices=db.RoundEnrollMode.choices(), coerce=db.RoundEnrollMode.coerce) + enroll_advert = wtforms.StringField("Popis v přihlášce") has_messages = wtforms.BooleanField("Zprávičky pro účastníky (aktivuje možnost vytvářet novinky zobrazované účastníkům)") submit = wtforms.SubmitField('Uložit') @@ -483,6 +501,8 @@ def org_round_edit(id: int): del form.score_winner_limit del form.score_successful_limit del form.points_step + # ani nastavení přihlašování + del form.enroll_mode if form.validate_on_submit(): form.populate_obj(round) diff --git a/mo/web/org_users.py b/mo/web/org_users.py index e885537a033c07024aed303dd034d15903535b95..2d00c0106e5da3f59976b7947c0c97e9f985ccd7 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -14,6 +14,7 @@ from wtforms.validators import Required import mo import mo.db as db +import mo.email from mo.rights import Right import mo.util import mo.users @@ -316,14 +317,15 @@ class ResendInviteForm(FlaskForm): resend_invite = SubmitField() def do(self, user: db.User): - token = mo.users.ask_reset_password(user) - db.get_session().commit() - if user.last_login_at is None and mo.util.send_new_account_email(user, token): - flash('Uvítací e-mail s odkazem pro nastavení hesla odeslán na {}'.format(user.email), 'success') - elif mo.util.send_password_reset_email(user, token): - flash('E-mail s odkazem pro resetování hesla odeslán na {}'.format(user.email), 'success') + if user.last_login_at is None: + token = mo.users.make_activation_token(user) + db.get_session().commit() + if mo.email.send_new_account_email(user, token): + flash('Uvítací e-mail s odkazem na aktivaci účtu odeslán na {}.'.format(user.email), 'success') + else: + flash('Problém při odesílání e-mailu s odkazem na aktivaci účtu.', 'danger') else: - flash('Problém při odesílání e-mailu s odkazem pro nastavení hesla', 'danger') + flash('Tento uživatel už má účet aktivovaný.', 'danger') @app.route('/org/org/<int:id>/', methods=('GET', 'POST')) @@ -339,7 +341,7 @@ def org_org(id: int): can_assign_rights = rr.have_right(Right.assign_rights) resend_invite_form: Optional[ResendInviteForm] = None - if rr.can_edit_user(user): + if user.last_login_at is None and rr.can_edit_user(user): resend_invite_form = ResendInviteForm() if resend_invite_form.resend_invite.data and resend_invite_form.validate_on_submit(): resend_invite_form.do(user) @@ -425,7 +427,7 @@ def org_user(id: int): rr = g.gatekeeper.rights_generic() resend_invite_form: Optional[ResendInviteForm] = None - if rr.can_edit_user(user): + if user.last_login_at is None and rr.can_edit_user(user): resend_invite_form = ResendInviteForm() if resend_invite_form.resend_invite.data and resend_invite_form.validate_on_submit(): resend_invite_form.do(user) @@ -567,17 +569,14 @@ def org_user_new(): details={'action': 'new', 'user': db.row2dict(new_user)}, ) + token = mo.users.make_activation_token(new_user) sess.commit() flash('Nový uživatel vytvořen', 'success') - # Send password (re)set link - token = mo.users.ask_reset_password(new_user) - db.get_session().commit() - - if mo.util.send_new_account_email(new_user, token): - flash('E-mail s odkazem pro nastavení hesla odeslán na {}'.format(new_user.email), 'success') + if mo.email.send_new_account_email(new_user, token): + flash('E-mail s odkazem na aktivaci účtu odeslán na {}.'.format(new_user.email), 'success') else: - flash('Problém při odesílání e-mailu s odkazem pro nastavení hesla', 'danger') + flash('Problém při odesílání e-mailu s odkazem na aktivaci účtu.', 'danger') if is_org: return redirect(url_for('org_org', id=new_user.user_id)) diff --git a/mo/web/templates/acct_activate.html b/mo/web/templates/acct_activate.html new file mode 100644 index 0000000000000000000000000000000000000000..1117b6c929457c315e2a02dd79f7beb42bb87d4b --- /dev/null +++ b/mo/web/templates/acct_activate.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Aktivace nového účtu{% endblock %} +{% block body %} + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} + +{% endblock %} diff --git a/mo/web/templates/acct_confirm_email.html b/mo/web/templates/acct_confirm_email.html new file mode 100644 index 0000000000000000000000000000000000000000..616790bd47f7d09913cef924e357c53fd5ea304e --- /dev/null +++ b/mo/web/templates/acct_confirm_email.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Změna e-mailu{% endblock %} +{% block body %} + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} + +{% endblock %} diff --git a/mo/web/templates/acct_reg1.html b/mo/web/templates/acct_reg1.html new file mode 100644 index 0000000000000000000000000000000000000000..558cac93abb32164cb83b2ebe28b8b6bd9637f74 --- /dev/null +++ b/mo/web/templates/acct_reg1.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Založení účtu{% endblock %} +{% block body %} + +<p>Nejprve vyplňte svou e-mailovou adresu, která také bude sloužit jako přihlašovací jméno. +Na ni vám pošleme ověřovací e-mail. + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} + +{% endblock %} diff --git a/mo/web/templates/acct_reg2.html b/mo/web/templates/acct_reg2.html new file mode 100644 index 0000000000000000000000000000000000000000..3372d16f9d72f49a5632cbae7cd6bf772298e422 --- /dev/null +++ b/mo/web/templates/acct_reg2.html @@ -0,0 +1,19 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Založení účtu{% endblock %} +{% block body %} + +{% if form %} + +<p>S údaji o účtu budeme zacházet v souladu se <a href='{{ url_for('doc_gdpr') }}'>zásadami +zpracování osobních údajů</a>. + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} + +{% else %} + +<p>Počkejte prosím, až vám přijde e-mail a klikněte na odkaz v něm uvedený. + +{% endif %} + +{% endblock %} diff --git a/mo/web/templates/acct_reset_passwd.html b/mo/web/templates/acct_reset_passwd.html new file mode 100644 index 0000000000000000000000000000000000000000..1342edeada97075dacdba3ecbe58076d737b5551 --- /dev/null +++ b/mo/web/templates/acct_reset_passwd.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Nastavení nového hesla{% endblock %} +{% block body %} + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} +{{ wtf.quick_form(cancel_form, form_type='horizontal') }} + +{% endblock %} diff --git a/mo/web/templates/doc_about.html b/mo/web/templates/doc_about.html index 51f0d0fe392a22e807cf07286591395985d306b3..2aa703cc204d238049c05d2aeff71811da8c2347 100644 --- a/mo/web/templates/doc_about.html +++ b/mo/web/templates/doc_about.html @@ -12,6 +12,8 @@ <p>z <a href='https://www.mff.cuni.cz/'>Matematicko-fyzikální fakulty</a> <a href='https://www.cuni.cz/'>Univerzity Karlovy</a> v Praze. MFF UK také děkujeme za poskytnutí serveru, kde systém běží. +<h3>Správce systému</h3> + <p>Veškeré připomínky k chodu systému a nápady na další rozvoj prosím posílejte e-mailem na {{ config.MAIL_CONTACT|mailto }}. diff --git a/mo/web/templates/login.html b/mo/web/templates/login.html index ccef7950b1482dfac0a648d15cf7f523ea98c96c..255154712a7761f2ea35da7088cd61299eab746e 100644 --- a/mo/web/templates/login.html +++ b/mo/web/templates/login.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}Login{% endblock %} +{% block title %}Přihlášení uživatele{% endblock %} {% block body %} <form method="POST" class="form form-horizontal" action="{{ url_for('login') }}"> @@ -11,6 +11,7 @@ <div class="btn-group col-lg-offset-2"> {{ wtf.form_field(form.submit, class="btn btn-primary") }} {{ wtf.form_field(form.reset) }} + <a class='btn btn-default' href='{{ url_for('create_acct') }}'>Založit nový účet</a> </div> </form> diff --git a/mo/web/templates/main.html b/mo/web/templates/main.html index 631596ce2444769b6c4a03a855e80dcc07d191cb..e742505d32c975a8f3520fb528a94372a05acc76 100644 --- a/mo/web/templates/main.html +++ b/mo/web/templates/main.html @@ -1,15 +1,11 @@ {% extends "base.html" %} {% block title %}Vítejte{% endblock %} {% block body %} - <p>Na tomto webu je možné odevzdávat řešení úloh Matematické olympiády. - Momentálně je dostupný pouze uživatelům, kteří získali účet při postupu - do dalšího kola MO. - <p>Pokud účet máte, tak se prosím přihlašte. + <p>Na odevzdávání potřebujete účet. Pokud už ho máte, tak se prosím přihlašte. + V opačném případě si účet založte. - <p>Pokud jste postoupili, ale účet dosud nemáte, zkontrolujte prosím - svou složku na nevyžádanou poštu, zda pozvánka neuvízla tam. Nepomůže-li - to, obraťte se prosím na svého učitele matematiky nebo na správce - tohoto systému (odkaz viz patička). + <p><a class='btn btn-primary' href='{{ url_for('login') }}'>Přihlásit se</a> + <a class='btn btn-primary' href='{{ url_for('create_acct') }}'>Založit nový účet</a> {% endblock %} diff --git a/mo/web/templates/org_index.html b/mo/web/templates/org_index.html index 73a21f886d3be32aea08d14005d44e0d0e0e022d..e4a5b0f5011b8458a24583502c2dadb683287f0e 100644 --- a/mo/web/templates/org_index.html +++ b/mo/web/templates/org_index.html @@ -41,9 +41,9 @@ <li><a href='{{ url_for('doc_garant') }}'>Návod pro garanty</a> (může se hodit i ostatním organizátorům) <li><a href='{{ url_for('static', filename='doc/import-navod.pdf') }}'>Podrobnější návod k importům</a> (PDF) <li>Export všech škol: - <a href='{{ url_for('org_export_skoly', format='en_csv') }}'>CSV s čárkami</a>, - <a href='{{ url_for('org_export_skoly', format='cs_csv') }}'>CSV se středníky</a>, - <a href='{{ url_for('org_export_skoly', format='tsv') }}'>TSV</a> + <a href='{{ url_for('org_export_schools', format='en_csv') }}'>CSV s čárkami</a>, + <a href='{{ url_for('org_export_schools', format='cs_csv') }}'>CSV se středníky</a>, + <a href='{{ url_for('org_export_schools', format='tsv') }}'>TSV</a> <li><a href='https://docs.google.com/document/d/1XXk7Od-ZKtfmfNa-9FpFjUqmy0Ekzf2-2q3EpSWyn1w/edit?usp=sharing'>Návod na tvorbu PDF</a> </ul> diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html index 2d13b18136d95d201618c4a06af1acfce84f6a19..ddddc7df8f2f622a83a951de62106679b7c53834 100644 --- a/mo/web/templates/org_round.html +++ b/mo/web/templates/org_round.html @@ -34,6 +34,8 @@ {% with state=round.ct_state() %} <tr><td>Stav pro účastníky<td class='rstate-{{state.name}}'>{{ state.friendly_name() }} {% endwith %} + <tr><td>Režim přihlašování<td>{{ round.enroll_mode.friendly_name() }} + <tr><td>Popis v přihlášce<td>{{ round.enroll_advert }} </table> <table class=data style="float: left;"> <thead> diff --git a/mo/web/templates/org_rounds.html b/mo/web/templates/org_rounds.html index fd336634167c91193585aa49edd2a173495440df..d4b415dc7e863a0df32fd04f885522194ae67780 100644 --- a/mo/web/templates/org_rounds.html +++ b/mo/web/templates/org_rounds.html @@ -23,4 +23,10 @@ <td class='rstate-{{r.state.name}}'>{{ r.state.friendly_name() }} {% endfor %} </table> + +{% if history %} + <p><a class='btn btn-default' href='{{ url_for('org_rounds') }}'>Aktuální ročník</a> +{% else %} + <p><a class='btn btn-default' href='{{ url_for('org_rounds_history') }}'>Všechny ročníky</a> +{% endif %} {% endblock %} diff --git a/mo/web/templates/org_user.html b/mo/web/templates/org_user.html index 966d4b94591f593b6f012aca424ba3a5ddaabc6b..05463d5fedf7d748cda5aef70109032cae7decbb 100644 --- a/mo/web/templates/org_user.html +++ b/mo/web/templates/org_user.html @@ -24,7 +24,7 @@ <form method=POST class='btn-group' onsubmit='return confirm("Poslat účastníkovi e-mail s odkazem na vytvoření hesla?");'> {{ resend_invite_form.csrf_token }} <button class="btn btn-default" type='submit' name='resend_invite' value='yes'> - {% if user.last_login_at %}Resetovat heslo{% else %}Znovu poslat zvací e-mail{% endif %} + Znovu poslat zvací e-mail </button> </form> {% endif %} diff --git a/mo/web/templates/reset.html b/mo/web/templates/reset.html deleted file mode 100644 index bb382b0a53a39d60c42807dcbc086493163dcdb6..0000000000000000000000000000000000000000 --- a/mo/web/templates/reset.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends "base.html" %} -{% import "bootstrap/wtf.html" as wtf %} -{% block title %}Nastavení nového hesla{% endblock %} -{% block body %} - - <form method="POST" class="form form-horizontal" action=""> - {{ form.csrf_token }} - {{ form.token() }} - {{ wtf.form_field(form.email, form_type='horizontal') }} - {{ wtf.form_field(form.passwd, form_type='horizontal') }} - <div class="btn-group col-lg-offset-2"> - {{ wtf.form_field(form.submit, class="btn btn-primary") }} - {{ wtf.form_field(form.cancel) }} - </div> - </form> - -{% endblock %} diff --git a/mo/web/templates/settings.html b/mo/web/templates/settings.html index 654eff586fe0292c5e248981087baa8f2f3f8cec..32c42fa002f3a512104eff3a8c1ac125abd5a921 100644 --- a/mo/web/templates/settings.html +++ b/mo/web/templates/settings.html @@ -1,14 +1,34 @@ {% extends "base.html" %} -{% block title %}Uživatel {{ g.user.full_name() }}{% endblock %} +{% block title %}Uživatel {{ user.full_name() }}{% endblock %} {% block body %} + <h3>Osobní údaje</h3> + + <table class=table> + <tr><th>Jméno<td>{{ user.first_name }} + <tr><th>Příjmení<td>{{ user.last_name }} + <tr><th>E-mail<td>{{ user.email }} +{% if pant %} + <tr><th>Škola<td>{{ pant.school_place.name }} + <tr><th>Ročník<td>{{ pant.grade }} + <tr><th>Rok narození<td>{{ pant.birth_year }} +{% endif %} + </table> + + <p><a class='btn btn-primary' href='{{ url_for('user_settings_change') }}'>Změnit e-mail nebo heslo</a> + + <p>Pokud potřebujete změnit jiné údaje, ozvěte se svému učiteli nebo garantovi. + Neuspějete-li u nich, napište správci OSMO (kontakt viz patička stránky). + +{% if user.is_admin or user.is_org %} + <h3>Práva</h3> -{% if g.user.is_admin %} +{% if user.is_admin %} <p>Správce systému {% endif %} -{% if g.user.is_org %} +{% if user.is_org %} <p>Organizátor s následujícími rolemi: - <table class=data> + <table class=table> <tr> <th>Role <th>Oblast @@ -25,8 +45,7 @@ {% endfor %} </table> {% endif %} -{% if not g.user.is_admin and not g.user.is_org %} - <p>Běžný uživatel + {% endif %} {% endblock %} diff --git a/mo/web/templates/settings_change.html b/mo/web/templates/settings_change.html new file mode 100644 index 0000000000000000000000000000000000000000..568ca0fa6402ee006d649222486e398baaf91e74 --- /dev/null +++ b/mo/web/templates/settings_change.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block title %}Změna osobních údajů{% endblock %} +{% block body %} + +{{ wtf.quick_form(form, form_type='horizontal', button_map={'submit': 'primary'}) }} + +{% endblock %} diff --git a/mo/web/templates/user_contest.html b/mo/web/templates/user_contest.html index 147bc9ab0432159148d4dbaf17a7d67d99bcde44..848ceac4682196f019e08b8e60e16f8b79443f4a 100644 --- a/mo/web/templates/user_contest.html +++ b/mo/web/templates/user_contest.html @@ -29,7 +29,18 @@ {% endif %} {% endif %} -{% if state == RoundState.preparing %} +{% if part_state == PartState.registered %} +<p> + Vaše přihláška do této soutěže <b>dosud nebyla potvrzena organizátory.</b> + Vyčkejte prosím. + {% set state = RoundState.preparing %} +</p> +{% elif part_state == PartState.refused %} +<p> + Vaše přihláška do této soutěže <b>byla odmítnuta organizátory.</b> + {% set state = RoundState.preparing %} +</p> +{% elif state == RoundState.preparing %} <p> Soutěžní kolo se <b>připravuje</b>{% if round.ct_tasks_start and round.ct_tasks_start > g.now %}, začne <b>{{ round.ct_tasks_start|time_and_timedelta }}</b>{% endif %}. @@ -64,12 +75,12 @@ Pokud si s tvorbou PDF nevíte rady, zkuste se podívat do <a href='https://docs <p>Soutěž se nachází v neznámém stavu. To by se nemělo stát :) {% endif %} +{% if state != RoundState.preparing %} + {% if contest.ct_task_statement_available() %} <p>Můžete si stáhnout <a href='{{ url_for('user_task_statement', id=contest.contest_id) }}'>zadání úloh</a>. {% endif %} -{% if state != RoundState.preparing %} - <h3>Úlohy</h3> <table class="table table-bordered table-hover"> diff --git a/mo/web/templates/user_index.html b/mo/web/templates/user_index.html index 27e5b95e2e5372058f77a9fa9bbd0f852a974d15..440b9a16f664150213f456f110b2329d6680a3cd 100644 --- a/mo/web/templates/user_index.html +++ b/mo/web/templates/user_index.html @@ -42,4 +42,6 @@ <p>Momentálně se neúčastníte žádného kola MO. {% endif %} +<p><a class="btn btn-primary" href="{{ url_for('user_join') }}">Přihlásit se do další kategorie</a> + {% endblock %} diff --git a/mo/web/templates/user_join_list.html b/mo/web/templates/user_join_list.html new file mode 100644 index 0000000000000000000000000000000000000000..3f7796f59683b234b2c938110ac261f9c1d0db85 --- /dev/null +++ b/mo/web/templates/user_join_list.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Přihláška do MO{% endblock %} +{% block body %} + +{% if available_rounds %} + <p>Zde si můžete vybrat, do kterých kategorií olympíády se přihlásíte. + + <table class="table"> + <thead> + <tr> + <th>Kategorie + <th>Popis + <th>Kolo + <th>Termíny + <th>Odkazy + <tbody> + {% for round in available_rounds %} + <tr> + <td><b>{{ round.category }}</b> + <td>{{ round.enroll_advert }}</b> + <td>{{ round.name }} + <td>{{ round.format_times() }} + {% if round.round_id in pcrs_by_round_id %} + <td>Již přihlášen + {% else %} + <td><a href='{{ url_for('user_join_round', round_id=round.round_id) }}' class='btn btn-xs btn-primary'>Přihlásit se</a> + {% endif %} + {% endfor %} + </table> +{% else %} + <p>V tomto školním roce zatím nejsou otevřené žádné kategorie olympiády. + Zkuste to prosím později. +{% endif %} + +{% if pcrs_by_round_id %} +{# Není-li účastník přihlášen v žádné soutěží, user_index přesměrovává zpět na tuto stránku. #} +<p><a class="btn btn-default" href="{{ url_for('user_index') }}">Zpět</a> +{% endif %} + +{% endblock %} diff --git a/mo/web/templates/user_join_round.html b/mo/web/templates/user_join_round.html new file mode 100644 index 0000000000000000000000000000000000000000..d11437659439ccec3c18258a53db3b52b894a7a8 --- /dev/null +++ b/mo/web/templates/user_join_round.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} +{% import "bootstrap/wtf.html" as wtf %} +{% block head %} +<script src='{{ asset_url('js/osmo.js') }}'></script> +<script> + osmo_api_root = {{ url_for('api_root')|jsescape }}; + osmo_school = new OSMOSchool(); + {% if form.town_query.data %} + osmo_school.prefill_town_query = {{ form.town_query.data|jsescape }}; + {% if form.town_list.data %} + osmo_school.prefill_town_list = {{ form.town_list.data|jsescape }}; + {% if form.school.data %} + osmo_school.prefill_school = {{ form.school.data|jsescape }}; + {% endif %} + {% endif %} + {% endif %} + document.addEventListener('DOMContentLoaded', () => { osmo_school.init() }); +</script> +{% endblock %} +{% block title %}Přihláška{% endblock %} +{% block body %} + +<h3 style='margin-bottom: 21px'>{{ round.name }} kategorie {{ round.category }} {% if round.enroll_advert %} ({{ round.enroll_advert }}){% endif %}</h3> + +<form action="" method="POST" role="form" class="form form-horizontal"> + {{ form.csrf_token }} + {% if form.school %} + <div id='town_query-group' class='form-group row required{% if form.school.errors %} has-error{% endif %}'> + <label class='col-sm-2 control-label' for='town_query'>Škola</label> + <div class='col-sm-8'> + <input autofocus="" class="form-control" id="town_query" name="town_query" type="text" value=""> + <div id='town_query-help' class='help-block'> + Zadejte prvních pár znaků jména obce a zmáčkněte Hledat. + Pokud se vám nedaří školu najít (například proto, že studujete v zahraničí), + informujte prosím <a href='{{ url_for('doc_about') }}'>správce OSMO</a>. + </div> + </div> + <div class='col-sm-2'> + <button class='btn btn-primary' type='button' onclick='osmo_school.find_town(false)'> + Hledat + </button> + </div> + </div> + <div id='town_list-div' class='form-group row' style='display: none'> + <div class='col-sm-offset-2 col-sm-10'> + <select id='town_list' name='town_list' onchange='osmo_school.town_picked()'> + </select> + </div> + </div> + <div id='school-div' name='school' class='form-group row' style='display: none'> + <div class='col-sm-offset-2 col-sm-10'> + <select id='school' name='school'> + </select> + </div> + </div> + {{ wtf.form_field(form.grade, form_type='horizontal', horizontal_columns=('sm', 2, 10)) }} + {{ wtf.form_field(form.birth_year, form_type='horizontal', horizontal_columns=('sm', 2, 10)) }} + <p>Přihlášením do soutěže udělujete souhlas se <a href='{{ url_for('doc_gdpr') }}'>zpracováním osobních údajů</a>. + U nezletilých účastníků musí přihlášku odeslat zákonný zástupce. + {% else %} + <p>Vaše osobní údaje už známe z ostatních kategorií. Stačí tedy potvrdit přihlášení. + {% endif %} + <div class='form-group'> + <div class='col-sm-12'> + {{ wtf.form_field(form.submit, form_type='inline', button_map={'submit': 'primary'}) }} + <a class="btn btn-default" href="{{ url_for('user_join') }}">Zpět</a> + </div> + </div> +</form> + +{% endblock %} diff --git a/mo/web/user.py b/mo/web/user.py index 9d5c2df8165c2e152e59c72f43a97f44e15ce242..ee53d404bccf1edcc9f0fd24b9aeb363a922ee66 100644 --- a/mo/web/user.py +++ b/mo/web/user.py @@ -1,4 +1,4 @@ -from flask import render_template, jsonify, g, redirect, url_for, flash +from flask import render_template, jsonify, g, redirect, url_for, flash, request from flask_wtf import FlaskForm import flask_wtf.file from sqlalchemy import and_ @@ -6,24 +6,28 @@ from sqlalchemy.orm import joinedload from typing import List, Tuple 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 import mo.submit import mo.util from mo.util import logger from mo.util_format import time_and_timedelta from mo.web import app +import mo.web.fields as mo_fields +import mo.web.org_round import mo.web.util @app.route('/user/') def user_index(): pcrs = load_pcrs() - if getattr(config, 'AUTO_REGISTER_TEST', False) and not any(round.category == 'T' for pion, contest, round in pcrs): - if register_to_test(): - pcrs = load_pcrs() + + if not pcrs: + return redirect(url_for('user_join')) return render_template( 'user_index.html', @@ -39,41 +43,212 @@ def load_pcrs() -> List[Tuple[db.Participation, db.Contest, db.Round]]: .filter(db.Participation.user == g.user) .filter(db.Round.year == mo.current_year) .options(joinedload(db.Contest.place)) - .order_by(db.Round.year.desc(), db.Round.category, db.Round.seq, db.Round.part) + .order_by(db.Round.category, db.Round.seq, db.Round.part) .all()) -def register_to_test() -> bool: +@app.route('/user/join/') +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(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) + .all()) + available_rounds = [r for r in available_rounds if not r.is_subround()] + + pcrs = load_pcrs() + pcrs_by_round_id = {pcr[1].round_id: pcr for pcr in pcrs} + + return render_template( + 'user_join_list.html', + available_rounds=available_rounds, + pcrs_by_round_id=pcrs_by_round_id, + ) + + +class JoinRoundForm(FlaskForm): + # Zadávání školy je JS hack implementovaný v šabloně. Fields definují jen rozhraní. + school = mo_fields.School("Škola", validators=[Required()]) + town_query = wtforms.HiddenField() + town_list = wtforms.HiddenField() + + grade = mo_fields.Grade("Třída", validators=[Required()]) + birth_year = mo_fields.BirthYear("Rok narození", validators=[Required()]) + submit = wtforms.SubmitField('Přihlásit se') + + +@app.route('/user/join/<int:round_id>/', methods=('GET', 'POST')) +def user_join_round(round_id): sess = db.get_session() - round = sess.query(db.Round).filter_by(year=mo.current_year, category='T', seq=1, part=0).one_or_none() + round = sess.query(db.Round).get(round_id) if not round: - app.logger.error(f'Nemohu najít kolo {mo.current_year}-T-1') - return False + raise werkzeug.exceptions.NotFound() - if round.level != 0: - app.logger.error(f'Kolo {round.round_code_short()} není na celostátní úrovni') - return False + if (round.year != mo.current_year + or round.part > 1 + or round.enroll_mode not in [db.RoundEnrollMode.register, db.RoundEnrollMode.confirm] + or round.state != db.RoundState.running): + flash('Do této kategorie se není možné přihlásit.', 'danger') + return redirect(url_for('user_register')) - contest = sess.query(db.Contest).filter_by(round=round).limit(1).one_or_none() - if not contest: - app.logger.error(f'Kolo {round.round_code_short()} nemá soutěž') - return False + pion = (sess.query(db.Participation) + .select_from(db.Participation) + .filter_by(user=g.user) + .join(db.Participation.contest) + .filter(db.Contest.round == round) + .with_for_update() + .one_or_none()) + if pion: + flash('Do této kategorie už jste přihlášen.', 'info') + return redirect(url_for('user_join')) + + pant = (sess.query(db.Participant) + .filter_by(user=g.user, year=round.year) + .with_for_update() + .one_or_none()) + + form = JoinRoundForm() + if pant: + del form.school + del form.grade + del form.birth_year + + if form.validate_on_submit(): + if form.submit.data: + if not pant: + pant = join_create_pant(form) + sess.add(pant) + contest = join_create_contest(round, pant) + join_create_pion(contest) + sess.commit() + join_notify(contest) + + msg = 'Přihláška přijata.' + if round.enroll_mode == db.RoundEnrollMode.confirm: + msg += ' Ještě ji musí potvrdit organizátor soutěže.' + flash(msg, 'success') + return redirect(url_for('user_index')) + elif not pant and request.method == 'GET': + # Pokusíme se předvyplnit data z minulých ročníků + prev_pant = (sess.query(db.Participant) + .filter_by(user=g.user) + .options(joinedload(db.Participant.school_place, db.Place.parent_place)) + .order_by(db.Participant.year.desc()) + .limit(1).one_or_none()) + if prev_pant: + form.school.data = f'#{prev_pant.school}' + town = prev_pant.school_place.parent_place + form.town_query.data = town.name + form.town_list.data = str(town.place_id) + form.birth_year.data = prev_pant.birth_year + + return render_template( + 'user_join_round.html', + round=round, + form=form, + ) - pion = db.Participation(user=g.user, contest=contest, place=contest.place, state=db.PartState.registered) - sess.add(pion) - sess.flush() + +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, + school_place=form.school.place, + grade=form.grade.data, + birth_year=form.birth_year.data) + + logger.info(f'Join: Účastník #{g.user.user_id} se přihlásil do {pant.year}. ročníku') mo.util.log( type=db.LogType.participant, what=g.user.user_id, - details={'action': 'add-to-contest', 'new': db.row2dict(pion)}, + details={'action': 'create-participant', 'reason': 'user-join', 'new': db.row2dict(pant)}, ) - sess.commit() - app.logger.info(f'Účastník #{g.user.user_id} automaticky registrován do soutěže #{contest.contest_id}') - return True + return pant -def get_contest(id: int) -> db.Contest: +def join_create_contest(round: db.Round, pant: db.Participant) -> db.Contest: + sess = db.get_session() + + place = pant.school_place + if place.level != round.level: + parents = db.get_place_parents(pant.school_place) + places = [p for p in parents if p.level == round.level] + assert len(places) == 1 + place = places[0] + # XXX: Z rekurzivního dotazu nedostaneme plnohodnotný db.Place, ale jenom named tuple, tak musíme pracovat s ID. + place_id = place.place_id + + assert round.part <= 1 + c = (sess.query(db.Contest) + .filter_by(round=round, place_id=place_id) + .with_for_update() + .one_or_none()) + if not c: + c = db.Contest( + round=round, + place_id=place_id, + state=db.RoundState.running, + ) + sess.add(c) + sess.flush() + c.master = c + + logger.info(f'Join: Automaticky založena soutěž #{c.contest_id} {round.round_code()} pro místo #{place_id}') + mo.util.log( + type=db.LogType.contest, + what=c.contest_id, + details={'action': 'created', 'reason': 'user-join'}, + ) + + mo.web.org_round.create_subcontests(round, c) + + return c + + +def join_create_pion(c: db.Contest) -> None: + sess = db.get_session() + + if c.round.enroll_mode == db.RoundEnrollMode.register: + state = db.PartState.active + else: + state = db.PartState.registered + p = db.Participation(user=g.user, contest=c, place=c.place, state=state) + sess.add(p) + + logger.info(f'Join: Účastník #{g.user.user_id} přihlášen do soutěže #{c.contest_id}') + mo.util.log( + type=db.LogType.participant, + what=g.user.user_id, + details={'action': 'add-to-contest', 'reason': 'user-join', 'new': db.row2dict(p)}, + ) + + +def join_notify(c: db.Contest) -> None: + sess = db.get_session() + r = c.round + place = c.place + while place is not None: + uroles = (sess.query(db.UserRole) + .filter(db.UserRole.role.in_((db.RoleType.garant, db.RoleType.garant_kraj, db.RoleType.garant_okres, db.RoleType.garant_skola))) + .filter_by(place_id=place.place_id) + .options(joinedload(db.UserRole.user)) + .all()) + notify = [ur.user for ur in uroles if ur.applies_to(at=place, year=r.year, cat=r.category, seq=r.seq)] + if notify: + for org in notify: + logger.info(f'Join: Notifikuji orga <{org.email}> pro místo {place.get_code()}') + mo.email.send_join_notify_email(org, g.user, c) + return + place = place.parent_place + + logger.warn('Join: Není komu poslat mail') + + +def get_contest_pion(id: int, require_reg: bool = True) -> Tuple[db.Contest, db.Participation]: contest = (db.get_session().query(db.Contest) .options(joinedload(db.Contest.place), joinedload(db.Contest.round)) @@ -82,13 +257,20 @@ def get_contest(id: int) -> db.Contest: if not contest: raise werkzeug.exceptions.NotFound() - # FIXME: Kontrolovat nějak pion.state? pion = (db.get_session().query(db.Participation) .filter_by(user=g.user, contest_id=contest.master_contest_id) .one_or_none()) if not pion: raise werkzeug.exceptions.Forbidden() + if require_reg and pion.state in [db.PartState.registered, db.PartState.refused]: + raise werkzeug.exceptions.Forbidden() + + return contest, pion + + +def get_contest(id: int) -> db.Contest: + contest, _ = get_contest_pion(id) return contest @@ -105,7 +287,7 @@ def get_task(contest: db.Contest, id: int) -> db.Task: @app.route('/user/contest/<int:id>/') def user_contest(id: int): sess = db.get_session() - contest = get_contest(id) + contest, pion = get_contest_pion(id, require_reg=False) messages = sess.query(db.Message).filter_by(round_id=contest.round_id).order_by(db.Message.created_at).all() @@ -121,6 +303,7 @@ def user_contest(id: int): return render_template( 'user_contest.html', contest=contest, + part_state=pion.state, task_sols=task_sols, messages=messages, max_submit_size=config.MAX_CONTENT_LENGTH, diff --git a/static/js/osmo.js b/static/js/osmo.js new file mode 100644 index 0000000000000000000000000000000000000000..02605c9ebe58004a6cd636d08056304cad4bf116 --- /dev/null +++ b/static/js/osmo.js @@ -0,0 +1,154 @@ +/* + * JavaScriptové funkce pro OSMO + */ + +'use strict'; + +let osmo_api_root = undefined; + +/*** Výběr škol ***/ + +class OSMOSchool { + constructor() { + this.prefill_town_query = undefined; + this.prefill_town_list = undefined; + this.prefill_school = undefined; + } + + find_town_error(msg) { + document.getElementById('town_query-group').classList.add('has-error'); + document.getElementById('town_query-help').innerText = msg; + } + + async do_find_town(query) { + let resp = undefined; + try { + resp = await fetch(osmo_api_root + 'find-town?q=' + encodeURIComponent(query)); + } catch (err) { + console.log('OSMO: Search failed: ' + err); + this.find_town_error('Spojení se serverem selhalo.'); + return; + } + + if (resp.status !== 200) { + console.log('OSMO: Search status: ' + resp.status); + this.find_town_error('Spojení se serverem selhalo.'); + return; + } + + const ans = await resp.json(); + if (ans.error !== undefined) { + this.find_town_error(ans.error); + return; + } + + const list = document.getElementById('town_list'); + const opts = list.options; + opts.length = 0; + opts.add(new Option('Vyberte obec ze seznamu', "")); + for (const t of ans.found) { + opts.add(new Option(t[1], t[0])); + } + + document.getElementById('town_list-div').style.display = 'block'; + document.getElementById('town_query-help').innerText = ans.msg + + if (this.prefill_town_list !== undefined) { + list.value = this.prefill_town_list; + this.prefill_town_list = undefined; + this.town_picked(); + } else if (ans.found.length == 1) { + list.selectedIndex = 1; + this.town_picked(); + } + } + + find_town(during_init) { + const query = document.getElementById('town_query').value; + + document.getElementById('town_list-div').style.display = 'none'; + document.getElementById('school-div').style.display = 'none'; + document.getElementById('town_query-help').innerText = 'Hledám...'; + if (!during_init) { + document.getElementById('town_query-group').classList.remove('has-error'); + } + + this.do_find_town(query); + } + + town_keydown(event) { + if (event.key === 'Enter') { + event.preventDefault(); + this.find_town(false); + } + } + + async do_get_schools(town_id) { + let resp = undefined; + try { + resp = await fetch(osmo_api_root + 'get-schools?town=' + encodeURIComponent(town_id)); + } catch (err) { + console.log('OSMO: Search failed: ' + err); + return; + } + + if (resp.status !== 200) { + console.log('OSMO: Search status: ' + resp.status); + return; + } + + const ans = await resp.json(); + const list = document.getElementById('school'); + const opts = list.options; + list.replaceChildren(); + if (ans.zs.length > 0 || ans.ss.length > 0) { + opts.add(new Option('Vyberte školu ze seznamu', "")); + if (ans.zs.length > 0) { + const g = document.createElement('optgroup'); + g.label = 'Základní školy' + for (const s of ans.zs) { + g.append(new Option(s.name, '#' + s.id)); + } + opts.add(g); + } + if (ans.ss.length > 0) { + const g = document.createElement('optgroup'); + g.label = 'Střední školy' + for (const s of ans.ss) { + g.append(new Option(s.name, '#' + s.id)); + } + opts.add(g); + } + } else { + opts.add(new Option('V této obci nejsou žádné školy.', "")); + } + + document.getElementById('school-div').style.display = 'block'; + + if (this.prefill_school !== undefined) { + list.value = this.prefill_school; + this.prefill_school = undefined; + } + } + + town_picked() { + const town_id = document.getElementById('town_list').value; + document.getElementById('school-div').style.display = 'none'; + if (town_id != "") { + this.do_get_schools(town_id); + } + } + + init() { + console.log('OSMO: Init schools'); + + var tq = document.getElementById('town_query'); + tq.addEventListener('keydown', (event) => { this.town_keydown(event); }); + + if (this.prefill_town_query !== undefined) { + tq.value = this.prefill_town_query; + this.prefill_town_query = undefined; + this.find_town(true); + } + } +}