Skip to content
Snippets Groups Projects
Commit ee3c84d2 authored by Martin Mareš's avatar Martin Mareš
Browse files

Registrace do kategorie + výběr školy pomocí JS

Účastník ve stavech registered a refused má omezená práva
parent f614eaa6
Branches
No related tags found
1 merge request!86Registrace
......@@ -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
'''))
......@@ -115,6 +115,7 @@ app.assets.add_assets([
'bootstrap.min.css',
'mo.css',
'js/news-reloader.js',
'js/osmo.js',
])
......
......@@ -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">
......
......@@ -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 %}
{% 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 %}
{% 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 %}
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,
......
/*
* 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);
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment