diff --git a/mo/email.py b/mo/email.py index 597ce0044b60ff58f7e4f3c2e53f76f256362b83..812a7639b03c063d1a8dc929b94637fc1bba9f12 100644 --- a/mo/email.py +++ b/mo/email.py @@ -120,3 +120,26 @@ def send_confirm_change_email(user: db.User, token: str) -> bool: 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.' + else: + confirm = 'Přihláška byla schválena automaticky.' + + 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} + + Váš OSMO + ''')) diff --git a/mo/web/__init__.py b/mo/web/__init__.py index efde098980c9a047258e3961d29cdc2bb02b7421..b303ff34de05e973182bf138c8cf8bafc4fd772c 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', ]) 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..453771ec50e907bed4ce9711567f01d3a46ffd3a --- /dev/null +++ b/mo/web/templates/user_join_list.html @@ -0,0 +1,38 @@ +{% 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>Odkazy + <tbody> + {% for round in available_rounds %} + <tr> + <td><b>{{ round.category }}</b> + <td>{{ round.enroll_advert }}</b> + <td>{{ round.name }} + {% 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 fa8fb961fd860324870fed854b6474b3f3780708..9d77622d73f3a1e7dbf5e0679ff9cfc01dd7f780 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,15 +6,18 @@ 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.util @@ -22,6 +25,9 @@ import mo.web.util def user_index(): pcrs = load_pcrs() + if not pcrs: + return redirect(url_for('user_join')) + return render_template( 'user_index.html', pions=pcrs, @@ -36,11 +42,210 @@ 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 get_contest(id: int) -> db.Contest: +@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).get(round_id) + if not round: + raise werkzeug.exceptions.NotFound() + + if (round.year != mo.current_year + or round.part != 0 + 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')) + + 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, + ) + + +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': 'create-participant', 'reason': 'user-join', 'new': db.row2dict(pant)}, + ) + + return pant + + +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 == 0 # U kol s více částmi bude potřeba založit všechny contesty, ale přihlásit jen do primárního + 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'}, + ) + + 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)) @@ -49,13 +254,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 @@ -72,7 +284,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() @@ -88,6 +300,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); + } + } +}