diff --git a/constraints.txt b/constraints.txt index fa19acdaa0ad12cbc89e11be82e184fd8aa4d86c..9c4dbd343b0917fdd6123a6f3370488ea8a965fc 100644 --- a/constraints.txt +++ b/constraints.txt @@ -1,4 +1,5 @@ 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 diff --git a/db/db.ddl b/db/db.ddl index 73a341395035c1a40837c13faaa97cff85dff2ca..7e27eb955a61e9ab922a18fdf60801f9699f9e5b 100644 --- a/db/db.ddl +++ b/db/db.ddl @@ -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 +); diff --git a/db/upgrade-20210322.sql b/db/upgrade-20210322.sql new file mode 100644 index 0000000000000000000000000000000000000000..305d2e742cb1e149dc781f077f742fd7829c1e85 --- /dev/null +++ b/db/upgrade-20210322.sql @@ -0,0 +1,16 @@ +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 +); diff --git a/mo/db.py b/mo/db.py index 4f8fa8ee55caf5129dffffbc8dc78b3894229eb6..5d2c50189a63c27e52d5a01d9a96d216c8eb8e14 100644 --- a/mo/db.py +++ b/mo/db.py @@ -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 diff --git a/mo/web/__init__.py b/mo/web/__init__.py index 57d40439ae83733a020ea5f2f68b0c024c609c71..61f8fa51511f74d58db02cf711501a11b4640cdb 100644 --- a/mo/web/__init__.py +++ b/mo/web/__init__.py @@ -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', ]) diff --git a/mo/web/org_round.py b/mo/web/org_round.py index 979093b19910c10f96cda26db941ac362ffd935c..4ac98010b07a3e1d8e43c550a4c416fd05c3f410 100644 --- a/mo/web/org_round.py +++ b/mo/web/org_round.py @@ -1,6 +1,9 @@ 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, + ) diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html index 6f4a2a560f656e64f0036bb4ca0582659fe636ab..6c5705dc35258985d0b6b711ac38ae08ee7e4e2e 100644 --- a/mo/web/templates/org_round.html +++ b/mo/web/templates/org_round.html @@ -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 %} diff --git a/mo/web/templates/org_round_messages.html b/mo/web/templates/org_round_messages.html new file mode 100644 index 0000000000000000000000000000000000000000..1be2a7367b9db1ef420ee6ad65f7a19d925270f4 --- /dev/null +++ b/mo/web/templates/org_round_messages.html @@ -0,0 +1,34 @@ +{% 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 %} diff --git a/mo/web/templates/parts/messages.html b/mo/web/templates/parts/messages.html new file mode 100644 index 0000000000000000000000000000000000000000..d90436f4af4b3bcb363f896936d3ba7ab1249099 --- /dev/null +++ b/mo/web/templates/parts/messages.html @@ -0,0 +1,14 @@ +{% 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 %} diff --git a/mo/web/templates/parts/user_news.html b/mo/web/templates/parts/user_news.html new file mode 100644 index 0000000000000000000000000000000000000000..b873d2058644274e61161ae5d7f1f661efd86019 --- /dev/null +++ b/mo/web/templates/parts/user_news.html @@ -0,0 +1,6 @@ +<div id="novinky"> +{% include "parts/messages.html" %} +</div> +<script type="text/javascript"> +r = new NewsReloader(document.getElementById("novinky"), "{{ url }}", 60000); +</script> diff --git a/mo/web/templates/user_contest.html b/mo/web/templates/user_contest.html index d784945c5db5d002c8ff05b20ec330bf12e360bd..c676c07394e491d7d3be1be614f5f24cdf039fee 100644 --- a/mo/web/templates/user_contest.html +++ b/mo/web/templates/user_contest.html @@ -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 %} diff --git a/mo/web/templates/user_contest_task.html b/mo/web/templates/user_contest_task.html index 525f1071e9614bfbb259c8c826eaa19cb478ac2a..01c3263b12f025e2e890a5b7520b3645d587a96d 100644 --- a/mo/web/templates/user_contest_task.html +++ b/mo/web/templates/user_contest_task.html @@ -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 %} diff --git a/mo/web/user.py b/mo/web/user.py index a4cf524e43b7ff02b808b82a8ca097feaa683793..e54667f5e6b7cfc28ac7022658cafa175ef0e269 100644 --- a/mo/web/user.py +++ b/mo/web/user.py @@ -1,17 +1,17 @@ -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, ) diff --git a/setup.py b/setup.py index 5b7705e6c8a4f95cd17c41da0ca88e673970ecb9..2d45478b040f681c819e30e037937a6ffc4cc267 100644 --- a/setup.py +++ b/setup.py @@ -23,11 +23,13 @@ setuptools.setup( 'Flask-WTF', 'WTForms', 'bcrypt', + 'bleach', 'blinker', 'click', 'dateutils', 'flask_bootstrap', 'flask_sqlalchemy', + 'markdown', 'pikepdf', 'psycopg2', 'sqlalchemy', diff --git a/static/js/news-reloader.js b/static/js/news-reloader.js new file mode 100644 index 0000000000000000000000000000000000000000..04fe721d2224264a2c76932ecdf0d413ee9bb73b --- /dev/null +++ b/static/js/news-reloader.js @@ -0,0 +1,81 @@ +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(); + } +} diff --git a/static/mo.css b/static/mo.css index 9826de39d25461d82bfbf4df2e5c60e7bfc90c22..dec13006ac04a3a4b4393624fa95db3c9aeca644 100644 --- a/static/mo.css +++ b/static/mo.css @@ -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; +}