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

Merge branch 'jirka/messages' into 'devel'

Zprávičky

See merge request !57
parents af17ab2c 792f070d
No related branches found
No related tags found
1 merge request!57Zprávičky
bcrypt==3.2.0
bleach==3.3.0
blinker==1.4
cffi==1.14.4
click==7.1.2
......@@ -11,6 +12,7 @@ Flask-WTF==0.14.3
itsdangerous==1.1.0
Jinja2==2.11.2
lxml==4.6.2
markdown==3.3.4
MarkupSafe==1.1.1
pikepdf==2.3.0
Pillow==8.1.0
......
......@@ -107,6 +107,7 @@ CREATE TABLE rounds (
score_mode score_mode NOT NULL DEFAULT 'basic', -- mód výsledkovky
score_winner_limit int DEFAULT NULL, -- bodový limit na označení za vítěze
score_successful_limit int DEFAULT NULL, -- bodový limit na označení za úspěšného řešitele
has_messages boolean NOT NULL DEFAULT false, -- má zprávičky
UNIQUE (year, category, seq, part)
);
......@@ -298,3 +299,15 @@ CREATE TABLE jobs (
in_file varchar(255) DEFAULT NULL,
out_file varchar(255) DEFAULT NULL
);
-- Zprávičky k soutěžím
CREATE TABLE messages (
message_id serial PRIMARY KEY,
round_id int NOT NULL REFERENCES rounds(round_id),
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, -- čas publikování zprávičky
created_by int NOT NULL REFERENCES users(user_id), -- autor zprávičky
title text NOT NULL,
markdown text NOT NULL,
html text NOT NULL
);
SET ROLE 'mo_osmo';
-- Zprávičky k soutěžím
ALTER TABLE rounds
ADD COLUMN has_messages boolean NOT NULL DEFAULT false; -- má zprávičky
CREATE TABLE messages (
message_id serial PRIMARY KEY,
round_id int NOT NULL REFERENCES rounds(round_id),
created_at timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, -- čas publikování zprávičky
created_by int NOT NULL REFERENCES users(user_id), -- autor zprávičky
title text NOT NULL,
markdown text NOT NULL,
html text NOT NULL
);
......@@ -209,6 +209,7 @@ class Round(Base):
score_mode = Column(Enum(RoundScoreMode, name='score_mode'), nullable=False, server_default=text("'basic'::score_mode"))
score_winner_limit = Column(Integer)
score_successful_limit = Column(Integer)
has_messages = Column(Boolean, nullable=False, server_default=text("false"))
master = relationship('Round', primaryjoin='Round.master_round_id == Round.round_id', remote_side='Round.round_id', post_update=True)
......@@ -592,6 +593,20 @@ class Job(Base):
user = relationship('User')
class Message(Base):
__tablename__ = 'messages'
message_id = Column(Integer, primary_key=True, server_default=text("nextval('messages_message_id_seq'::regclass)"))
round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False)
created_at = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP"))
created_by = Column(Integer, ForeignKey('users.user_id'))
title = Column(Text, nullable=False)
markdown = Column(Text, nullable=False)
html = Column(Text, nullable=False)
created_by_user = relationship('User')
_engine: Optional[Engine] = None
_session: Optional[Session] = None
flask_db: Any = None
......
......@@ -114,6 +114,7 @@ mo.ext.assets.Assets(app, url_prefix='/assets', asset_dir=static_dir)
app.assets.add_assets([
'bootstrap.min.css',
'mo.css',
'js/news-reloader.js',
])
......
from flask import render_template, g, redirect, url_for, flash, request
import locale
from flask_wtf.form import FlaskForm
import bleach
from bleach.sanitizer import ALLOWED_TAGS
import markdown
from sqlalchemy import func
from sqlalchemy.orm import joinedload
from sqlalchemy.sql.functions import coalesce
......@@ -428,6 +431,7 @@ class RoundEditForm(FlaskForm):
"Hranice bodů pro úspěšné řešitele", validators=[validators.Optional()],
description="Řešitelé s alespoň tolika body budou označeni za úspěšné řešitele, prázdná hodnota = žádné neoznačovat",
)
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')
def validate_state(self, field):
......@@ -489,3 +493,78 @@ def org_task_statement(id: int):
raise werkzeug.exceptions.Forbidden()
return mo.web.util.send_task_statement(round)
class MessageAddForm(FlaskForm):
title = wtforms.StringField('Nadpis', validators=[validators.Required()])
markdown = wtforms.TextAreaField(
'Text novinky', description='Zprávičky lze formátovat pomocí Markdownu',
validators=[validators.Required()],
render_kw={'rows': 10},
)
submit = wtforms.SubmitField(label='Vložit zprávičku')
preview = wtforms.SubmitField(label='Zobrazit náhled')
class MessageRemoveForm(FlaskForm):
message_id = wtforms.IntegerField(validators=[validators.Required()])
message_remove = wtforms.SubmitField()
@app.route('/org/contest/r/<int:id>/messages/', methods=('GET', 'POST'))
def org_round_messages(id: int):
sess = db.get_session()
round, _, rr = get_round_rr(id, None, True)
if not round.has_messages:
flash('Toto kolo nemá aktivní zprávičky pro účastníky, aktivujte je v nastavení kola', 'warning')
return redirect(url_for('org_round', id=id))
messages = sess.query(db.Message).filter_by(round_id=id).order_by(db.Message.created_at).all()
add_form: Optional[MessageAddForm] = None
remove_form: Optional[MessageRemoveForm] = None
preview: Optional[db.Message] = None
if rr.have_right(Right.manage_round):
add_form = MessageAddForm()
remove_form = MessageRemoveForm()
if remove_form.validate_on_submit() and remove_form.message_remove.data:
msg = sess.query(db.Message).get(remove_form.message_id.data)
if not msg or msg.round_id != id:
raise werkzeug.exceptions.NotFound()
sess.delete(msg)
sess.commit()
app.logger.info(f"Zprávička pro kolo {id} odstraněna: {db.row2dict(msg)}")
flash('Zprávička odstraněna', 'success')
return redirect(url_for('org_round_messages', id=id))
if add_form.validate_on_submit():
msg = db.Message(
round_id=id,
created_by=g.user.user_id,
created_at=mo.now,
)
add_form.populate_obj(msg)
msg.html = bleach.clean(
markdown.markdown(msg.markdown),
tags=ALLOWED_TAGS+['p']
)
if add_form.preview.data:
preview = msg
elif add_form.submit.data:
sess.add(msg)
sess.commit()
app.logger.info(f"Vložena nová zprávička pro kolo {id}: {db.row2dict(msg)}")
flash('Zprávička úspěšně vložena', 'success')
return redirect(url_for('org_round_messages', id=id))
return render_template(
'org_round_messages.html',
round=round, rr=rr, messages=messages,
add_form=add_form, remove_form=remove_form,
preview=preview,
)
......@@ -58,6 +58,9 @@
{% if can_manage_round %}
<a class="btn btn-default" href='{{ url_for('org_round_edit', id=round.round_id) }}'>Editovat nastavení kola</a>
{% endif %}
{% if round.has_messages %}
<a class="btn btn-default" href='{{ url_for('org_round_messages', id=round.round_id) }}'>Zprávičky</a>
{% endif %}
{% if g.user.is_admin %}
<a class="btn btn-default" href='{{ log_url('round', round.round_id) }}'>Historie</a>
{% endif %}
......
{% extends "base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block title %}{{ round.name }} {{ round.round_code() }} – zprávičky{% endblock %}
{% block breadcrumbs %}
{{ contest_breadcrumbs(round=round, action='Zprávičky') }}
{% endblock %}
{% block body %}
{% if messages %}
{% with message_remove_form=remove_form %}
{% include "parts/messages.html" %}
{% endwith %}
{% else %}<p><i>Žádné zprávičky k vypsání.</i></p>{% endif %}
{% if add_form %}
<h3>Nová zprávička</h3>
<div class="form-frame">
{{ wtf.quick_form(add_form, button_map={'submit': 'primary', 'preview': 'default'}) }}
</div>
{% if preview %}
<div class="form-frame">
<b>Náhled zprávičky:</b>
{% with messages = [preview] %}
{% include "parts/messages.html" %}
{% endwith %}
</div>
{% endif %}
{% else %}
<p><i>Nemáte právo k přidávání nových zpráviček.</i></p>
{% endif %}
{% endblock %}
{% for msg in messages %}
<div class="message">
<span class="msg-title">{{ msg.title }}</span>
<span class="msg-date">{{ msg.created_at|time_and_timedelta }}</span>
<div class="msg-text">{{ msg.html|safe }}</div>
{% if message_remove_form %}
<form method="POST" onsubmit="return confirm('Opravdu nenávratně smazat?');" style="float: right; margin-top: -20px;">
{{ message_remove_form.csrf_token }}
<input type="hidden" name="message_id" value="{{ msg.message_id }}">
<input type="submit" name="message_remove" class="btn btn-xs btn-danger" value="Smazat">
</form>
{% endif %}
</div>
{% endfor %}
<div id="novinky">
{% include "parts/messages.html" %}
</div>
<script type="text/javascript">
r = new NewsReloader(document.getElementById("novinky"), "{{ url }}", 60000);
</script>
......@@ -3,6 +3,12 @@
{% set round = contest.round %}
{% set state = contest.ct_state() %}
{% block head %}
{% if contest.round.has_messages %}
<script src="{{ asset_url('js/news-reloader.js') }}" type="text/javascript"></script>
{% endif %}
{% endblock %}
{% block title %}{{ round.name }} {{ round.year }}. ročníku kategorie {{ round.category }}: {{ contest.place.name }}{% endblock %}
{% block breadcrumbs %}
<li><a href='{{ url_for('user_index') }}'>Soutěže</a>
......@@ -98,4 +104,12 @@
</table>
{% endif %}
{% if contest.round.has_messages %}
<h3>Novinky k soutěži</h3>
{% with title="Novinky k soutěži", url=url_for('user_contest_news', id=contest.contest_id) %}
{% include "parts/user_news.html" %}
{% endwith %}
{% endif %}
{% endblock %}
......@@ -3,6 +3,12 @@
{% set round = contest.round %}
{% set state = contest.ct_state() %}
{% block head %}
{% if contest.round.has_messages %}
<script src="{{ asset_url('js/news-reloader.js') }}" type="text/javascript"></script>
{% endif %}
{% endblock %}
{% block title %}Úloha {{ task.code }}: {{ task.name }}{% endblock %}
{% block breadcrumbs %}
<li><a href='{{ url_for('user_index') }}'>Soutěže</a>
......@@ -85,4 +91,11 @@
{% endif %}
{% endif %}
{% if contest.round.has_messages %}
<h3>Novinky k soutěži</h3>
{% with title="Novinky k soutěži", url=url_for('user_contest_news', id=contest.contest_id) %}
{% include "parts/user_news.html" %}
{% endwith %}
{% endif %}
{% endblock %}
from flask import render_template, request, g, redirect, url_for, flash
from flask import render_template, jsonify, g, redirect, url_for, flash
from flask_wtf import FlaskForm
import flask_wtf.file
from sqlalchemy import and_
from sqlalchemy.orm import joinedload
import werkzeug.exceptions
import wtforms
import wtforms.validators as validators
import mo.config as config
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.util
......@@ -66,9 +66,12 @@ 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)
task_sols = (db.get_session().query(db.Task, db.Solution)
messages = sess.query(db.Message).filter_by(round_id=contest.round_id).order_by(db.Message.created_at).all()
task_sols = (sess.query(db.Task, db.Solution)
.select_from(db.Task)
.outerjoin(db.Solution, and_(db.Solution.task_id == db.Task.task_id, db.Solution.user == g.user))
.filter(db.Task.round == contest.round)
......@@ -81,10 +84,27 @@ def user_contest(id: int):
'user_contest.html',
contest=contest,
task_sols=task_sols,
messages=messages,
max_submit_size=config.MAX_CONTENT_LENGTH,
)
@app.route('/user/contest/<int:id>/news')
def user_contest_news(id: int):
sess = db.get_session()
contest = get_contest(id)
messages = sess.query(db.Message).filter_by(round_id=contest.round_id).order_by(db.Message.created_at).all()
out_messages = [{
'title': msg.title,
'date_format': time_and_timedelta(msg.created_at),
'body': msg.html,
} for msg in messages]
return jsonify(out_messages)
@app.route('/user/contest/<int:id>/task-statement/zadani.pdf')
def user_task_statement(id: int):
contest = get_contest(id)
......@@ -108,6 +128,8 @@ def user_contest_task(contest_id: int, task_id: int):
task = get_task(contest, task_id)
sess = db.get_session()
messages = sess.query(db.Message).filter_by(round_id=contest.round_id).order_by(db.Message.created_at).all()
state = contest.ct_state()
if state == db.RoundState.preparing:
# Dokud se kolo připravuje nebo čeká na zveřejnění zadání, tak ani nezobrazujeme
......@@ -164,6 +186,7 @@ def user_contest_task(contest_id: int, task_id: int):
sol=sol,
papers=papers,
form=form,
messages=messages,
)
......
......@@ -23,11 +23,13 @@ setuptools.setup(
'Flask-WTF',
'WTForms',
'bcrypt',
'bleach',
'blinker',
'click',
'dateutils',
'flask_bootstrap',
'flask_sqlalchemy',
'markdown',
'pikepdf',
'psycopg2',
'sqlalchemy',
......
class NewsReloader {
news_count = 0;
notification_interval = null;
original_title = "";
constructor(element, url, check_interval=60000) {
this.element = element;
this.url = url;
this.check_interval = check_interval;
this.news_count = element.childElementCount;
this.original_title = document.title;
var t = this
setInterval(function() { t.refreshNews();}, this.check_interval);
window.addEventListener('focus', function() {
t.news_count = t.element.childElementCount;
t.notificationOff();
});
}
notificationOn(notification) {
clearInterval(this.notification_interval); // clear any previous interval
var t = this;
this.notification_interval = setInterval(function() {
document.title = (t.original_title == document.title)
? notification + t.original_title
: t.original_title;
}, 1000);
}
notificationOff() {
if (this.notification_interval) {
clearInterval(this.notification_interval);
document.title = this.original_title;
}
}
refreshNews() {
var xmlhttp = new XMLHttpRequest();
var t = this;
xmlhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
var newsArr = JSON.parse(this.responseText);
var count = newsArr.length
var markN = 0; // how many elements to mark with class "new"
if (count > t.news_count) {
markN = count - t.news_count;
}
// Create all new <div>s
var newElements = document.createDocumentFragment();
for (var i = 0; i < count; i++) {
var div = document.createElement("div");
div.className = "message";
if (i + markN >= count) div.className += " new"; // mark N last elements
div.innerHTML = "<span class='msg-title'>" + newsArr[i]["title"] + "</span>"
+"<span class='msg-date'>" + newsArr[i]["date_format"] + "</span>"
+"<div class='msg-text'>" + newsArr[i]["body"] + "</div>";
newElements.appendChild(div);
}
// Remove all childs and append new
t.element.innerHTML = "";
t.element.appendChild(newElements);
// Notification
if (count != t.news_count) {
if (document.hasFocus()) t.news_count = count;
else {
// Add (1) to the title (with removing any previous value)
t.notificationOn("(" + Math.abs(count - t.news_count).toString() + ") ");
}
}
}
}
xmlhttp.open("GET", this.url, true);
xmlhttp.send();
}
}
......@@ -382,3 +382,24 @@ div.alert + div.alert {
.collapsible input[type="checkbox"].toggle:checked ~ .collapsible-inner {
max-height: 100vh;
}
div.message {
padding: 5px 10px;
margin-bottom: 5px;
border-radius: 5px;
border: solid 1px #f8d99b;
color:#46381f;
background-color:#fff8d5;
}
div.message.new {
background-color: #ffdede;
border: solid 1px #ff5e5e;
}
div.message .msg-title {
font-weight: bold;
}
div.message .msg-date {
float: right;
font-style: italic;
color: #777;
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment