From 6c86c3d40f55752e6d4c528b79a8d7ad0e3dd2d6 Mon Sep 17 00:00:00 2001
From: Martin Mares <mj@ucw.cz>
Date: Tue, 28 Sep 2021 20:49:43 +0200
Subject: [PATCH] =?UTF-8?q?Propojen=C3=AD=20s=20CMS?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

U praktických úloh nabízíme místo formuláře na odevzdání přechod do CMS
přes nově dodělaný single sign-on.
---
 etc/config.py.example                   |   5 ++
 mo/web/jinja.py                         |   1 +
 mo/web/templates/user_contest_task.html |  33 +++++--
 mo/web/user.py                          | 113 ++++++++++++++++--------
 4 files changed, 109 insertions(+), 43 deletions(-)

diff --git a/etc/config.py.example b/etc/config.py.example
index af31c885..5e3f8772 100644
--- a/etc/config.py.example
+++ b/etc/config.py.example
@@ -56,3 +56,8 @@ REG_TOKEN_VALIDITY = 10
 
 # Aktuální ročník MO
 CURRENT_YEAR = 71
+
+# Instance CMS, ve které žijí praktické programovací úlohy, a její SSO secret.
+# Pokud se neuvede nebo je None, praktické úlohy nejde odevzdávat.
+# CMS_ROOT = 'https://contest.kam.mff.cuni.cz/cms/'
+# CMS_SSO_SECRET = 'BrumBrum'
diff --git a/mo/web/jinja.py b/mo/web/jinja.py
index 04cb8b0c..46cf724e 100644
--- a/mo/web/jinja.py
+++ b/mo/web/jinja.py
@@ -42,6 +42,7 @@ app.jinja_env.globals.update(LogType=db.LogType)
 app.jinja_env.globals.update(PartState=db.PartState)
 app.jinja_env.globals.update(RoleType=db.RoleType)
 app.jinja_env.globals.update(PaperType=db.PaperType)
+app.jinja_env.globals.update(TaskType=db.TaskType)
 app.jinja_env.globals.update(JobType=db.JobType)
 app.jinja_env.globals.update(JobState=db.JobState)
 
diff --git a/mo/web/templates/user_contest_task.html b/mo/web/templates/user_contest_task.html
index 2521f054..2a6e4d01 100644
--- a/mo/web/templates/user_contest_task.html
+++ b/mo/web/templates/user_contest_task.html
@@ -23,15 +23,32 @@
 {% if state == RoundState.running %}
 	{% if contest.ct_can_submit() %}
 	<h3>Odevzdat řešení</h3>
-		{% if round.ct_submit_end and g.now > round.ct_submit_end %}
-			<p class="alert alert-danger">Pozor, odevzdáváte po termínu, uplynul {{ round.ct_submit_end|time_and_timedelta }}.
-			Vaše řešení nemusí být hodnoceno. Doporučujeme využít políčko pro poznámku a vysvětlit situaci.
+		{% if task.type == TaskType.regular %}
+			{% if round.ct_submit_end and g.now > round.ct_submit_end %}
+				<p class="alert alert-danger">Pozor, odevzdáváte po termínu, uplynul {{ round.ct_submit_end|time_and_timedelta }}.
+				Vaše řešení nemusí být hodnoceno. Doporučujeme využít políčko pro poznámku a vysvětlit situaci.
+			{% else %}
+				<p>Pokud jste se spletli a nahráli nesprávné či neúplné řešení, můžete ho nahradit
+				řešením správným. V tom případě však uveďte do poznámky, proč jste řešení
+				nahradili (např. nahráli jste omylem řešení jiné úlohy).
+			{% endif %}
+			{{ wtf.quick_form(form, form_type='basic', button_map={'submit': 'primary'}) }}
+		{% elif task.type == TaskType.cms and cms_params %}
+			<p>Tato úloha je praktická a odevzdává se do systému CMS. Odevzdaná řešení
+			se zde objeví až s koncem soutěže.
+
+			<form action='{{ cms_params.url }}' method=POST>
+				<input type=hidden name=username value="{{ cms_params.username }}">
+				<input type=hidden name=first_name value="{{ cms_params.first_name }}">
+				<input type=hidden name=last_name value="{{ cms_params.last_name }}">
+				<input type=hidden name=timestamp value="{{ cms_params.timestamp }}">
+				<input type=hidden name=signature value="{{ cms_params.signature }}">
+				<input type=hidden name=back_url value="{{ cms_params.back_url }}">
+				<input type=submit class='btn btn-primary' value="Přejít do CMS">
+			</form>
 		{% else %}
-			<p>Pokud jste se spletli a nahráli nesprávné či neúplné řešení, můžete ho nahradit
-			řešením správným. V tom případě však uveďte do poznámky, proč jste řešení
-			nahradili (např. nahráli jste omylem řešení jiné úlohy).
+			<p>Úloha s neznámým režimem odevzdávání.
 		{% endif %}
-		{{ wtf.quick_form(form, form_type='basic', button_map={'submit': 'primary'}) }}
 	{% else %}
 	<p>Již není možné odevzdat řešení, termín na odevzdávání vypršel.</p>
 	{% endif %}
@@ -57,7 +74,7 @@
 <p>Soutěž se nachází v neznámém stavu. To by se nemělo stát :)
 {% endif %}
 
-{% if sol or state == RoundState.running %}
+{% if sol or state == RoundState.running and task.type == TaskType.regular %}
 <h3>Historie vašich řešení</h3>
 
 {% if papers %}
diff --git a/mo/web/user.py b/mo/web/user.py
index 0ff79c8f..0ba45334 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -1,9 +1,12 @@
+from dataclasses import dataclass
 from flask import render_template, jsonify, g, redirect, url_for, flash, request
 from flask_wtf import FlaskForm
 import flask_wtf.file
+import hashlib
+import hmac
 from sqlalchemy import and_
 from sqlalchemy.orm import joinedload
-from typing import List, Tuple
+from typing import List, Tuple, Optional
 import werkzeug.exceptions
 import wtforms
 from wtforms.validators import Required
@@ -342,6 +345,34 @@ class SubmitForm(FlaskForm):
     submit = wtforms.SubmitField('Odevzdat')
 
 
+@dataclass
+class CMSParams:
+    url: str
+    username: str
+    first_name: str
+    last_name: str
+    timestamp: str
+    signature: str = ""
+    back_url: str = ""
+
+
+def get_cms_params() -> Optional[CMSParams]:
+    if not (hasattr(config, 'CMS_ROOT') and hasattr(config, 'CMS_SSO_SECRET')):
+        return None
+
+    p = CMSParams(
+        url=config.CMS_ROOT + 'sso-login',
+        username=f'osmo{g.user.user_id}',
+        first_name=g.user.first_name,
+        last_name=g.user.last_name,
+        timestamp=str(int(mo.now.timestamp())),
+    )
+    msg = ":".join((p.username, p.first_name, p.last_name, p.timestamp)).encode('utf-8')
+    key = config.CMS_SSO_SECRET.encode('us-ascii')
+    p.signature = hmac.HMAC(key, msg, digestmod=hashlib.sha256).hexdigest()
+    return p
+
+
 @app.route('/user/contest/<int:contest_id>/task/<int:task_id>/', methods=('GET', 'POST'))
 def user_contest_task(contest_id: int, task_id: int):
     contest = get_contest(contest_id)
@@ -356,40 +387,51 @@ def user_contest_task(contest_id: int, task_id: int):
         # stránku, abychom něco neprozradili jménem úlohy
         raise werkzeug.exceptions.Forbidden()
 
-    form = SubmitForm()
-    if contest.ct_can_submit() and form.validate_on_submit():
-        file = form.file.data.stream
-        paper = db.Paper(task=task, for_user_obj=g.user, uploaded_by_obj=g.user, type=db.PaperType.solution, note=form.note.data)
-        submitter = mo.submit.Submitter()
-
-        try:
-            submitter.submit_paper(paper, file.name)
-        except mo.submit.SubmitException as e:
-            flash(f'Chyba: {e}', 'danger')
-            return redirect(url_for('user_contest_task', contest_id=contest_id, task_id=task_id))
-
-        sess.add(paper)
-
-        # FIXME: Bylo by hezké použít INSERT ... ON CONFLICT UPDATE
-        # (SQLAlchemy to umí, ale ne přes ORM, jen core rozhraním)
-        sol = (sess.query(db.Solution)
-               .filter_by(task=task, user=g.user)
-               .with_for_update()
-               .one_or_none())
-        if sol is None:
-            sol = db.Solution(task=task, user=g.user)
-            sess.add(sol)
-        sol.final_submit_obj = paper
-
-        sess.commit()
-
-        if paper.is_broken():
-            flash('Soubor není korektní PDF, ale přesto jsme ho přijali a pokusíme se ho zpracovat. ' +
-                  'Zkontrolujte prosím, že se na vašem počítači zobrazuje správně.',
-                  'warning')
-        else:
-            flash('Řešení odevzdáno', 'success')
-        return redirect(url_for('user_contest', id=contest_id))
+    form: Optional[SubmitForm] = None
+    if task.type == db.TaskType.regular:
+        form = SubmitForm()
+        if contest.ct_can_submit() and form.validate_on_submit():
+            if task.type != db.TaskType.regular:
+                raise werkzeug.exceptions.Forbidden()
+
+            file = form.file.data.stream
+            paper = db.Paper(task=task, for_user_obj=g.user, uploaded_by_obj=g.user, type=db.PaperType.solution, note=form.note.data)
+            submitter = mo.submit.Submitter()
+
+            try:
+                submitter.submit_paper(paper, file.name)
+            except mo.submit.SubmitException as e:
+                flash(f'Chyba: {e}', 'danger')
+                return redirect(url_for('user_contest_task', contest_id=contest_id, task_id=task_id))
+
+            sess.add(paper)
+
+            # FIXME: Bylo by hezké použít INSERT ... ON CONFLICT UPDATE
+            # (SQLAlchemy to umí, ale ne přes ORM, jen core rozhraním)
+            sol = (sess.query(db.Solution)
+                   .filter_by(task=task, user=g.user)
+                   .with_for_update()
+                   .one_or_none())
+            if sol is None:
+                sol = db.Solution(task=task, user=g.user)
+                sess.add(sol)
+            sol.final_submit_obj = paper
+
+            sess.commit()
+
+            if paper.is_broken():
+                flash('Soubor není korektní PDF, ale přesto jsme ho přijali a pokusíme se ho zpracovat. ' +
+                      'Zkontrolujte prosím, že se na vašem počítači zobrazuje správně.',
+                      'warning')
+            else:
+                flash('Řešení odevzdáno', 'success')
+            return redirect(url_for('user_contest', id=contest_id))
+
+    cms_params: Optional[CMSParams] = None
+    if task.type == db.TaskType.cms:
+        cms_params = get_cms_params()
+        if cms_params:
+            cms_params.back_url = url_for('user_contest_task', contest_id=contest_id, task_id=task_id)
 
     sol = sess.query(db.Solution).filter_by(task=task, user=g.user).one_or_none()
 
@@ -406,6 +448,7 @@ def user_contest_task(contest_id: int, task_id: int):
         sol=sol,
         papers=papers,
         form=form,
+        cms_params=cms_params,
         messages=messages,
     )
 
-- 
GitLab