Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 126-global-file-attachment-storage
  • devel
  • master
3 results

Target

Select target project
  • owl/owl
  • jirikalvoda/owl
2 results
Select Git revision
  • auto
  • devel
  • imgsize
  • master
4 results
Show changes

Commits on Source 98

......@@ -11,7 +11,7 @@ The Owl was written by Martin Mareš <mj@ucw.cz>.
## License
The whole package is (c) 2020-2023 Martin Mareš.
The whole package is (c) 2020-2025 Martin Mareš.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
......@@ -34,3 +34,24 @@ Owl's repository contains embedded copies of several libraries:
- Swagger in `static/swagger/`, which is an official release obtained
from https://github.com/swagger-api/swagger-ui/.
## Running a test instance
First of all, install PostgreSQL, create a user and a database and initialize
the database using db/db.ddl (you might have to edit the "SET ROLE" at the top
and also collation rules for names).
Then copy etc/config.py.example to owl/config.py and edit parameters of the
database connection.
Create a Python virtual environment, activate it and run "pip install -e .".
Create a user with administrator rights using "bin/flask create-user --admin ...".
Create a semester using
INSERT INTO owl_semesters (ident, name) VALUES ('semester-ident', 'Semester Name');
Run the web application using "bin/flask run".
Create more users and courses using sub-commands of bin/flask ("--help" works).
......@@ -37,7 +37,7 @@ for d in $DEST/venv/lib/python*/site-packages/owl ; do
done
echo "Installing static"
rsync -r --delete static/ $DEST/static/
rsync -r --delete --perms --chmod=D755,F644 static/ $DEST/static/
if [ -e $DEST/var/uwsgi.fifo ] ; then
echo "Reloading uwsgi"
......
#!/bin/sh
exec flask --app owl --debug "$@"
export SQLALCHEMY_WARN_20=1
# exec flask --app owl --debug "$@"
exec venv/bin/python3 -W always::DeprecationWarning venv/bin/flask --app owl --debug "$@"
blinker==1.5
certifi==2022.9.24
charset-normalizer==2.1.1
click==8.1.3
blinker==1.9.0
certifi==2024.12.14
charset-normalizer==3.4.1
click==8.1.8
dateutils==0.6.12
Flask==2.2.2
Flask-SQLAlchemy==2.5.1
Flask-WTF==1.0.1
greenlet==1.1.3
idna==3.4
importlib-metadata==4.12.0
inflect==6.0.0
itsdangerous==2.1.2
Jinja2==3.1.2
lxml==4.9.1
MarkupSafe==2.1.1
mypy==0.971
mypy-extensions==0.4.3
psycopg2==2.9.3
Flask==3.1.0
Flask-SQLAlchemy==3.0.5
Flask-WTF==1.2.2
greenlet==3.1.1
idna==3.10
itsdangerous==2.2.0
Jinja2==3.1.5
lxml==5.3.0
MarkupSafe==3.0.2
mypy==1.14.1
mypy-extensions==1.0.0
psycopg2==2.9.10
python-cas==1.6.0
python-dateutil==2.8.2
python-dateutil==2.9.0.post0
python-magic==0.4.27
pytz==2022.2.1
requests==2.28.1
six==1.16.0
SQLAlchemy==1.4.41
pytz==2024.2
requests==2.32.3
six==1.17.0
SQLAlchemy==1.4.54
sqlalchemy-stubs==0.4
sqlalchemy2-stubs==0.0.2a27
sqlalchemy2-stubs==0.0.2a38
token-bucket==0.3.0
tomli==2.0.1
types-Flask-SQLAlchemy==2.5.9
types-python-dateutil==2.8.19
types-setuptools==65.3.0
types-SQLAlchemy==1.4.52
typing-extensions==4.3.0
urllib3==1.26.12
types-Flask-SQLAlchemy==2.5.9.4
types-python-dateutil==2.9.0.20241206
types-setuptools==75.6.0.20241223
types-SQLAlchemy==1.4.53.38
typing_extensions==4.12.2
urllib3==2.3.0
uwsgidecorators==1.1.0
Werkzeug==2.2.2
WTForms==3.0.1
zipp==3.8.1
Werkzeug==3.1.3
WTForms==3.2.1
......@@ -9,6 +9,7 @@ CREATE TABLE owl_users (
email varchar(255) DEFAULT NULL,
created timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_admin boolean NOT NULL DEFAULT false,
is_teacher boolean NOT NULL DEFAULT false, -- offer course creation
notify boolean NOT NULL DEFAULT false,
notify_self boolean NOT NULL DEFAULT false,
notify_att boolean NOT NULL DEFAULT true, -- send attachments in notification
......@@ -35,8 +36,10 @@ CREATE TABLE owl_courses (
enroll_token varchar(255) UNIQUE NOT NULL,
student_grading boolean NOT NULL DEFAULT FALSE,
anon_grading boolean NOT NULL DEFAULT FALSE,
split_grading boolean NOT NULL DEFAULT FALSE,
auto_deadline varchar(255) DEFAULT NULL, -- textual deadline specification
pass_threshold numeric(6, 2) DEFAULT NULL, -- points needed to pass the course
allow_zip_att boolean NOT NULL DEFAULT TRUE,
UNIQUE(semid, ident)
);
......@@ -71,9 +74,10 @@ CREATE TABLE owl_topics (
UNIQUE(cid, ident)
);
CREATE TABLE owl_student_graders (
CREATE TABLE owl_graders (
tid int NOT NULL REFERENCES owl_topics(tid) ON DELETE CASCADE,
uid int NOT NULL REFERENCES owl_users(uid) ON DELETE CASCADE,
student_grader boolean NOT NULL DEFAULT FALSE,
UNIQUE(tid, uid)
);
......
SET ROLE owl;
ALTER TABLE owl_courses ADD COLUMN
allow_zip_att boolean NOT NULL DEFAULT TRUE
;
SET ROLE owl;
ALTER TABLE owl_users ADD COLUMN
is_teacher boolean NOT NULL DEFAULT false
;
SET ROLE owl;
ALTER TABLE owl_courses ADD COLUMN
split_grading boolean NOT NULL DEFAULT FALSE
;
ALTER TABLE owl_student_graders RENAME TO owl_graders;
ALTER TABLE owl_graders ADD COLUMN
student_grader boolean NOT NULL DEFAULT FALSE;
UPDATE owl_graders SET student_grader=true;
......@@ -10,7 +10,7 @@ SQLALCHEMY_ENGINE_OPTIONS = {
CAS_SERVER = 'https://cas.cuni.cz/cas'
SECRET_KEY = "FIXME"
SESSION_COOKIE_PATH = '/owl/'
SESSION_COOKIE_PATH = '/'
SESSION_COOKIE_NAME = 'owl_session'
# This fixes Flask's insecure default: specifying a Domain in Set-Cookie header
......@@ -38,7 +38,7 @@ DISABLE_NOTIFY = False
# This is used to construct URLs in notifications:
SERVER_NAME = 'www.example.org'
APPLICATION_ROOT = '/owl'
APPLICATION_ROOT = '/'
# Site flavor (devel/test/pub), z toho CSS třída elementu <header>
WEB_FLAVOR = 'devel'
......
# Example configuration for Nginx
server {
# ... usual server settings ...
location /static {
alias /home/owl/owl/static;
try_files $uri =404;
}
location /assets {
location ~ ^/assets/[0-9a-f]+/(.*) {
alias /home/owl/owl/static/$1;
}
}
location / {
include uwsgi_params;
uwsgi_pass unix:/home/owl/owl/var/owl.sock;
}
}
......@@ -8,10 +8,24 @@ const macros = {
"\\O": "\\mathcal{O}",
};
const currentMacros = {};
function resetKaTeXMacros() {
Object.keys(currentMacros).forEach((key) => delete currentMacros[key]);
Object.assign(currentMacros, macros);
}
const katexOptions = {
'macros': currentMacros,
throwOnError: true,
logErrors: false,
errors: [],
};
const mk = require('@byronwan/markdown-it-katex')
md.use(mk, {
'macros': macros,
});
md.use(mk, katexOptions);
md.use(require('markdown-it-imsize'));
const hljs = require('highlight.js/lib/core')
hljs.registerLanguage('plaintext', require('highlight.js/lib/languages/plaintext'))
......@@ -36,6 +50,10 @@ exports.render_md = function () {
const cmt = document.getElementById("comment");
var i;
for (i = 0; i < pb.length; i++) {
resetKaTeXMacros();
katexOptions.errors = [];
const b = pb[i];
// b.style.backgroundColor = "blue";
const src = b.innerText;
......@@ -50,33 +68,62 @@ exports.render_md = function () {
}
}
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
exports.preview = function () {
console.log('Owl: Preview');
resetKaTeXMacros();
katexOptions.errors = [];
const src = document.getElementById('comment').value;
const pb = document.getElementById('previewbox');
const pv = document.getElementById('preview');
pv.innerHTML = md.render(src)
pv.innerHTML = md.render(src);
pb.style.display = "block";
[...pb.getElementsByClassName("errorbox")].forEach((e) => e.remove());
katexOptions.errors.forEach((error) => {
const eb = document.createElement("div");
eb.className = "errorbox";
eb.innerHTML = `<code>${escapeHtml(error.toString())}</code>`;
pb.appendChild(eb);
});
}
exports.add_ctrl_enter = function (f) {
if (f) {
f.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.keyCode == 13) {
exports.cancel_unload_check();
f.submit();
}
});
}
}
let do_unload_check = true;
exports.add_unload_check = function (f) {
if (f) {
const orig_contents = f.value;
window.addEventListener('beforeunload', (event) => {
if (f.value != orig_contents) {
if (do_unload_check && f.value != orig_contents) {
event.preventDefault();
}
});
}
}
exports.cancel_unload_check = function () {
do_unload_check = false;
}
This diff is collapsed.
......@@ -6,12 +6,13 @@
"author": "Martin Mareš <mj@ucw.cz>",
"license": "GPL-2.0",
"dependencies": {
"@byronwan/markdown-it-katex": "git+https://gitlab.kam.mff.cuni.cz/owl/markdown-it-katex.git#master",
"browserify": ">=16.5",
"katex": "^0.16.0",
"katex-fonts": "katex/katex-fonts",
"markdown-it": ">=11.0",
"markdown-it-highlightjs": ">=3.4.0",
"@byronwan/markdown-it-katex": ">=3.2.0",
"browserify": ">=16.5",
"markdown-it-imsize": "git+https://gitlab.kam.mff.cuni.cz/owl/markdown-it-imsize.git#master",
"tinyify": "browserify/tinyify"
}
}
import datetime
import dateutil.tz
from flask import Flask, request, g, session
import flask.logging
import flask.wrappers
from flask_sqlalchemy import SQLAlchemy
import json
import logging
......@@ -10,8 +10,9 @@ from sqlalchemy import select
from sqlalchemy.orm import joinedload
import sys
import tempfile
from typing import Callable
from typing import Callable, Optional
import werkzeug.exceptions
import werkzeug.formparser
import owl.assets as assets
import owl.config as config
......@@ -24,7 +25,7 @@ import owl.db as db
# To achieve that, we subclass Request to make it use a subclassed FormDataParser,
# which calls a custom stream factory.
def owl_stream_factory(total_content_length, filename, content_type, content_length=None):
def owl_stream_factory(total_content_length: Optional[int], content_type: Optional[str], filename: Optional[str], content_length: Optional[int]=None):
return tempfile.NamedTemporaryFile(dir=os.path.join(app.instance_path, 'tmp'), prefix='upload-')
......@@ -32,13 +33,8 @@ class FormDataParser(werkzeug.formparser.FormDataParser):
def __init__(self,
stream_factory=None,
charset='utf-8',
errors='replace',
max_form_memory_size=None,
max_content_length=None,
cls=None,
silent=True):
super().__init__(owl_stream_factory, charset, errors, max_form_memory_size, max_content_length, cls, silent)
*args, **kwargs):
super().__init__(stream_factory=owl_stream_factory, *args, **kwargs)
class Request(flask.wrappers.Request):
......@@ -56,12 +52,12 @@ class OwlFlask(Flask):
def get_now(self) -> datetime.datetime:
if not hasattr(g, 'now'):
g.now = datetime.datetime.now(tz=dateutil.tz.UTC)
g.now = datetime.datetime.now().astimezone()
return g.now
def get_user(self) -> db.User:
if not hasattr(g, 'user'):
g.user = db.get_session().scalar(select(db.User).filter_by(uid=g.uid))
g.user = db.get_session().get(db.User, g.uid)
assert g.user is not None
return g.user
......@@ -110,7 +106,7 @@ class OwlFlask(Flask):
if g.is_teacher:
g.is_grader = True
elif g.course.student_grading:
sg = db.get_session().scalar(select(db.StudentGrader).filter_by(topic=g.topic, uid=g.uid))
sg = db.get_session().scalar(select(db.Grader).filter_by(topic=g.topic, uid=g.uid, student_grader=True))
g.is_grader = (sg is not None)
else:
g.is_grader = False
......@@ -210,6 +206,7 @@ app.assets.add_assets([
'github.css',
'katex.min.css',
'owl.css',
'teacher.js',
])
......@@ -232,11 +229,13 @@ def init_request():
g.uid = session['uid']
g.name = session['name']
g.is_admin = ('admin' in session)
g.is_teacher_user = ('teacher' in session)
g.inline_att = session.get('inline', False)
else:
g.uid = None
g.name = None
g.is_admin = False
g.is_teacher_user = False
g.inline_att = False
g.course = None
......
......@@ -4,7 +4,7 @@ from cas import CASClient
from flask import g, request, url_for, render_template, redirect, session
from flask_wtf import FlaskForm
from sqlalchemy import select
from typing import Any
from typing import Any, Optional, List
import werkzeug.exceptions
import wtforms
import wtforms.validators as validators
......@@ -41,7 +41,7 @@ def login():
sess = db.get_session()
user = sess.scalar(select(db.User).filter_by(auth_token=token_form.key.data))
if user:
app.logger.info('Logged in user: uid=%s, cn=%s, admin=%s by key', user.uid, user.full_name(), user.is_admin)
app.logger.info('Logged in user: uid=%s, cn=%s, admin=%s, teacher=%s by key', user.uid, user.full_name(), user.is_admin, user.is_teacher)
session_from_db(user)
return redirect(token_form.next.data)
......@@ -84,7 +84,7 @@ def cas_login():
return redirect(next)
def parse_cas_emails(raw_email):
def parse_cas_emails(raw_email: str) -> List[str]:
if isinstance(raw_email, list):
return raw_email
elif raw_email == "":
......@@ -97,7 +97,8 @@ def parse_cas_emails(raw_email):
return [raw_email]
def primary_cas_email(raw_email):
def cas_primary_email(attrs) -> Optional[str]:
raw_email = attrs['mail']
emails = parse_cas_emails(raw_email)
if not emails:
return None
......@@ -108,18 +109,30 @@ def primary_cas_email(raw_email):
return emails[0]
def cas_is_teacher(attrs) -> bool:
affil = attrs.get('edupersonscopedaffiliation', [])
if isinstance(affil, str):
affil = [affil]
return 'faculty@mff.cuni.cz' in affil
def session_from_cas(cas_user: str, attrs: Any) -> None:
sess = db.get_session()
is_teacher = cas_is_teacher(attrs)
user = sess.scalar(select(db.User).filter_by(ukco=cas_user))
if user:
app.logger.info('Logged in user: uid=%s, ukco=%s, cn=%s, mail=%s, admin=%s', user.uid, user.ukco, user.full_name(), user.email, user.is_admin)
app.logger.info('Logged in user: uid=%s, ukco=%s, cn=%s, mail=%s, admin=%s, teacher=%s', user.uid, user.ukco, user.full_name(), user.email, user.is_admin, user.is_teacher)
if is_teacher and not user.is_teacher:
user.is_teacher = True
app.logger.info('Upgrading user #%s to teacher', user.uid)
else:
email = primary_cas_email(attrs['mail'])
email = cas_primary_email(attrs)
user = db.User(
ukco=cas_user,
first_name=attrs['givenname'],
last_name=attrs['sn'],
email=email,
is_teacher=is_teacher,
)
sess.add(user)
sess.flush() # To obtain UID
......@@ -137,6 +150,10 @@ def session_from_db(user: db.User):
session['admin'] = 1
else:
session.pop('admin', None)
if user.is_teacher:
session['teacher'] = 1
else:
session.pop('teacher', None)
session['inline'] = user.inline_att
......
......@@ -2,10 +2,10 @@
from collections import defaultdict
from datetime import timedelta
import dateutil.tz
from flask import render_template, g, request, flash, url_for, redirect
from flask_wtf import FlaskForm
import json
import re
import secrets
from sqlalchemy import select, and_, or_, insert, literal
import sqlalchemy.exc
......@@ -22,6 +22,7 @@ from owl.auto_deadline import AutoDeadline
import owl.api as api
import owl.auto_eval
import owl.db as db
import owl.fields as fields
import owl.notify
import owl.post
......@@ -59,16 +60,25 @@ student_grading_options = (('no', 'Disabled'), ('yes', 'Enabled'), ('anon', 'Ena
class CourseSettingsForm(FlaskForm):
ident = fields.Ident("Identifier:", validators=[validators.DataRequired()])
name = fields.String("Name:", validators=[validators.DataRequired()])
student_grading = wtforms.SelectField('Student grading:', choices=student_grading_options)
auto_deadline = wtforms.StringField('Default deadline:')
split_grading = wtforms.BooleanField("Split grading:")
auto_deadline = fields.String('Default deadline:')
submit = wtforms.SubmitField("Save settings")
pass_threshold = wtforms.DecimalField("Pass threshold:", validators=[validators.Optional()])
allow_zip_att = wtforms.BooleanField("ZIP attachments:")
def validate_auto_deadline(form, field):
if field.data and not AutoDeadline.parse(field.data):
raise wtforms.ValidationError("Incorrect syntax of deadline rule")
class AddTeacherForm(FlaskForm):
teacher = fields.String("E-mail or UKČO", validators=[validators.DataRequired()])
submit = wtforms.SubmitField("Add")
@app.route('/admin/course/<sident>/<cident>/', methods=('GET', 'POST'))
def admin_course(sident: str, cident: str, show_key: Optional[int] = None):
app.course_init(sident, cident)
......@@ -77,7 +87,9 @@ def admin_course(sident: str, cident: str, show_key: Optional[int] = None):
sess = db.get_session()
teacher_enrolls = sess.scalars(
select(db.Enroll)
.filter_by(course=g.course, is_teacher=True)
.join(db.Enroll.user)
.filter(db.Enroll.course == g.course, db.Enroll.is_teacher == True)
.order_by(db.User.last_name, db.User.first_name)
.options(joinedload(db.Enroll.user))
)
......@@ -90,32 +102,82 @@ def admin_course(sident: str, cident: str, show_key: Optional[int] = None):
key_form = ApiKeyForm(prefix='key_')
settings_form = CourseSettingsForm(
prefix='set_',
ident=g.course.ident,
name=g.course.name,
student_grading='no' if not g.course.student_grading else ('anon' if g.course.anon_grading else 'yes'),
split_grading=g.course.split_grading,
auto_deadline=g.course.auto_deadline,
pass_threshold=g.course.pass_threshold,
allow_zip_att=g.course.allow_zip_att,
)
add_teacher_form = AddTeacherForm(prefix='at_')
if settings_form.validate_on_submit():
if sess.scalar(select(db.Course).filter_by(semid=g.course.semid, ident=settings_form.ident.data).filter(db.Course.cid != g.course.cid)):
settings_form.ident.errors.append('This identifier is already used.')
else:
sg = settings_form.student_grading.data
g.course.ident = settings_form.ident.data
g.course.name = settings_form.name.data
g.course.student_grading = sg != 'no'
g.course.anon_grading = sg == 'anon'
g.course.split_grading = settings_form.split_grading.data
g.course.auto_deadline = settings_form.auto_deadline.data or None
g.course.pass_threshold = settings_form.pass_threshold.data
g.course.allow_zip_att = settings_form.allow_zip_att.data
changes = db.get_object_changes(g.course)
sess.commit()
flash('Course settings saved.', 'info')
return redirect(url_for('admin_course', sident=sident, cident=cident))
if changes:
app.logger.info(f'Course #{g.course.cid} modified by #{g.uid}: {changes}')
return redirect(url_for('admin_course', sident=sident, cident=g.course.ident))
if add_teacher_form.validate_on_submit():
assert add_teacher_form.teacher.data
if (err := add_teacher(add_teacher_form.teacher.data)):
flash(f'Cannot add the teacher: {err}', 'error')
else:
flash('Teacher added.', 'info')
return redirect(url_for('admin_course', sident=sident, cident=g.course.ident))
return render_template(
'admin-course.html',
teachers=[e.user.full_name() for e in teacher_enrolls],
teachers=[e.user for e in teacher_enrolls],
api_keys=api_keys,
key_form=key_form,
show_key=show_key,
settings_form=settings_form,
add_teacher_form=add_teacher_form,
)
def add_teacher(addr: str) -> Optional[str]:
sess = db.get_session()
if addr.isdecimal():
user = sess.scalar(select(db.User).filter_by(ukco=addr))
elif '@' in addr:
user = sess.scalar(select(db.User).filter_by(email=addr))
else:
return 'unrecognized identifier syntax.'
if user is None:
return 'user not found.'
enroll = sess.scalar(select(db.Enroll).filter_by(course=g.course, user=user))
if enroll:
if enroll.is_teacher:
return 'already teaches this course.'
else:
return 'already enrolled as a student of this course.'
enroll = db.Enroll(user=user, course=g.course, is_teacher=True)
sess.add(enroll)
sess.commit()
return None
class TopicBatchForm(FlaskForm):
apply = wtforms.SubmitField("Apply")
set_deadline = wtforms.BooleanField()
......@@ -153,7 +215,7 @@ def admin_topics(sident: str, cident: str):
for t in selected_topics:
deadline = batch_form.deadline.data
if deadline is not None:
deadline = deadline.astimezone(tz=dateutil.tz.UTC)
deadline = deadline.astimezone()
t.deadline = deadline
app.logger.info(f"Edited topic: tid={t.tid} by_uid={g.uid}")
if selected_topics:
......@@ -193,7 +255,7 @@ def admin_topics(sident: str, cident: str):
# API key management
class ApiKeyForm(FlaskForm):
comment = wtforms.StringField()
comment = fields.String()
@app.route('/admin/course/<sident>/<cident>/api-key/<int:key_id>/show', methods=('POST',))
......@@ -235,23 +297,22 @@ def api_key_show(sident: str, cident: str, key_id: Optional[int] = None):
class EditTopicForm(FlaskForm):
rank = wtforms.IntegerField("Rank:", validators=[validators.InputRequired()])
ident = wtforms.StringField("Ident:")
title = wtforms.StringField("Title:", validators=[validators.DataRequired()], render_kw={'size': 40})
ident = fields.Ident("Ident:")
title = fields.String("Title:", validators=[validators.DataRequired()], render_kw={'size': 40})
type = wtforms.SelectField("Type:", choices=api.topic_type_choices, validators=[validators.DataRequired()])
deadline = wtforms.DateTimeLocalField("Deadline:", format='%Y-%m-%dT%H:%M', validators=[validators.Optional()])
max_points = wtforms.DecimalField("Points:", validators=[validators.Optional()])
binary_result = wtforms.BooleanField("Binary result:")
standalone = wtforms.BooleanField("Stand-alone:")
auto_eval = wtforms.StringField("Evaluator:", render_kw={'size': 40})
auto_eval = fields.String("Evaluator:", render_kw={'size': 40})
custom_attrs = wtforms.TextAreaField("Custom attrs:", render_kw={'rows': 1, 'cols': 35})
public = wtforms.BooleanField("Public:")
submit = wtforms.SubmitField("Save")
publish = wtforms.SubmitField("Save and publish")
delete = wtforms.SubmitField("☠ Delete topic ☠")
def validate(self):
ret = FlaskForm.validate(self)
if not ret:
def validate(self, **kwargs):
if not super().validate(**kwargs):
return False
if self.type.data == 'H':
......@@ -273,9 +334,6 @@ class EditTopicForm(FlaskForm):
auto_eval = self.auto_eval.data or "" # None if the field is hidden
if self.type.data == 'S':
if not app.is_guinea_pig():
self.type.errors.append('Please ask the administrator for permission')
return False
if auto_eval == "":
self.auto_eval.errors.append('This topic type requires an evaluator')
return False
......@@ -361,10 +419,11 @@ def admin_edit_topic(sident: str, cident: str, tid: Optional[int] = None,
return redirect(url_for('admin_topics', sident=sident, cident=cident))
else:
topic = edit_topic
app.logger.info(f"Editing topic: cid={cid} tid={topic.tid} by_uid={g.uid}")
deadline = form.deadline.data
if deadline is not None:
deadline = deadline.astimezone(tz=dateutil.tz.UTC)
deadline = deadline.astimezone()
topic.ident = form.ident.data if form.ident.data != "" else None
topic.rank = form.rank.data
......@@ -377,11 +436,18 @@ def admin_edit_topic(sident: str, cident: str, tid: Optional[int] = None,
topic.standalone = form.standalone.data
topic.auto_eval = form.auto_eval.data or None
topic.custom_attrs = form.custom_attrs.data or None
is_modified = sess.is_modified(topic)
if form.publish.data:
publish_topic(edit_topic)
msg = 'Topic published.'
elif copy_topic is not None:
try:
sess.commit()
except sqlalchemy.exc.IntegrityError:
sess.rollback()
app.logger.info('Rolling back edit')
flash('Duplicate topic identifier', 'error')
return render_template('admin-edit-topic.html', form=form, edit_topic=edit_topic, copy_topic=copy_topic, parent_course=parent_course, post_count=count_posts(edit_topic), auto_deadline=auto_deadline)
done = []
if copy_topic is not None:
# Copy all posts
app.logger.info(f"Cloning posts: from_tid={copy_topic.tid} to_tid={topic.tid} by_uid={g.uid}")
now = app.get_now()
......@@ -399,20 +465,19 @@ def admin_edit_topic(sident: str, cident: str, tid: Optional[int] = None,
# Mark them as read by the current user
owl.post.update_seen(topic, -1, now)
msg = 'Topic copied.'
else:
app.logger.info(f"Edited topic: tid={topic.tid} by_uid={g.uid}")
msg = 'Topic saved.'
try:
sess.commit()
except sqlalchemy.exc.IntegrityError:
sess.rollback()
app.logger.info('Rolling back edit')
flash('Duplicate topic identifier', 'error')
return render_template('admin-edit-topic.html', form=form, edit_topic=edit_topic, copy_topic=copy_topic, parent_course=parent_course, post_count=count_posts(edit_topic), auto_deadline=auto_deadline)
done.append('copied')
elif is_modified:
done.append('saved')
flash(msg, 'info')
if form.publish.data:
publish_topic(topic) # Does an implicit commit
done.append('published')
if done:
flash('Topic ' + " and ".join(done) + '.', 'info')
else:
flash('No changes.', 'info')
return redirect(url_for('admin_topics', sident=sident, cident=cident))
else:
topic = copy_topic or edit_topic
......@@ -423,9 +488,11 @@ def admin_edit_topic(sident: str, cident: str, tid: Optional[int] = None,
else:
form = EditTopicForm(obj=topic)
if topic.deadline:
form.deadline.data = topic.deadline.replace(tzinfo=dateutil.tz.UTC).astimezone()
form.deadline.data = topic.deadline.astimezone()
if copy_topic:
form.public.data = False
if form.public.data:
form.publish.render_kw = {'disabled': True}
return render_template(
'admin-edit-topic.html',
......@@ -491,7 +558,7 @@ def course_choices(include_course: db.Course):
sem_ids = (
select(db.Semester.semid)
.order_by(db.Semester.rank.desc())
.limit(2)
.filter_by(advertised=True)
.subquery()
)
......@@ -623,7 +690,9 @@ class AssignGraderForm(FlaskForm):
assign = wtforms.SubmitField()
unassign = wtforms.SubmitField()
def validate(self):
def validate(self, **kwargs):
if not super().validate(**kwargs):
return False
if self.assign.data and self.unassign.data:
return False
return True
......@@ -635,41 +704,97 @@ def admin_topic_graders(sident: str, cident: str, tid: int):
topic = g.topic
sess = db.get_session()
if not g.course.student_grading:
raise werkzeug.exceptions.Forbidden("Course does not allow student grading")
if not g.course.student_grading and not g.course.split_grading:
raise werkzeug.exceptions.Forbidden("Course does not allow student/split grading")
if request.method == 'POST':
form = AssignGraderForm()
if not sess.scalar(select(db.Enroll).filter_by(uid=form.uid.data, course=g.course, is_teacher=False)):
raise werkzeug.exceptions.BadRequest("Given uid is not a student of this course")
enroll = sess.scalar(select(db.Enroll).filter_by(uid=form.uid.data, course=g.course))
if not enroll:
raise werkzeug.exceptions.BadRequest("Given uid is not enrolled in this course")
if (enroll.is_teacher and not g.course.split_grading
or not enroll.is_teacher and not g.course.student_grading):
raise werkzeug.exceptions.BadRequest("Wrong type of enrollment")
sg = sess.scalar(select(db.StudentGrader).filter_by(tid=topic.tid, uid=form.uid.data))
gr = sess.scalar(select(db.Grader).filter_by(tid=topic.tid, uid=form.uid.data))
if form.assign.data:
if sg:
raise werkzeug.exceptions.BadRequest("This student is already assigned")
sg = db.StudentGrader(topic=topic, uid=form.uid.data)
sess.add(sg)
if gr:
raise werkzeug.exceptions.BadRequest("This user is already assigned")
gr = db.Grader(topic=topic, uid=form.uid.data, student_grader=not enroll.is_teacher)
sess.add(gr)
else:
if not sg:
raise werkzeug.exceptions.BadRequest("This student is already not assigned")
sess.delete(sg)
if not gr:
raise werkzeug.exceptions.BadRequest("This user is already not assigned")
sess.delete(gr)
sess.commit()
return redirect(url_for('admin_topic_graders', sident=sident, cident=cident, tid=tid))
else:
usgs = sess.execute(
select(db.User, db.StudentGrader)
ueg = sess.execute(
select(db.User, db.Enroll, db.Grader)
.select_from(db.Enroll)
.filter(db.Enroll.course == g.course)
.filter(db.Enroll.is_teacher == False)
.join(db.User, db.User.uid == db.Enroll.uid)
.outerjoin(db.StudentGrader, and_(db.StudentGrader.topic == topic, db.StudentGrader.uid == db.User.uid))
.outerjoin(db.Grader, and_(db.Grader.topic == topic, db.Grader.uid == db.User.uid))
.order_by(db.User.last_name, db.User.first_name)
).all()
s_unassigned = [(u, AssignGraderForm(uid=u.uid)) for u, sg in usgs if sg is None]
s_assigned = [(u, AssignGraderForm(uid=u.uid)) for u, sg in usgs if sg is not None]
s_unassigned_students = [(u, AssignGraderForm(uid=u.uid)) for u, e, g in ueg if not e.is_teacher and g is None]
s_unassigned_teachers = [(u, AssignGraderForm(uid=u.uid)) for u, e, g in ueg if e.is_teacher and g is None]
s_assigned = [(u, e.is_teacher, AssignGraderForm(uid=u.uid)) for u, e, g in ueg if g is not None]
return render_template(
'admin-topic-graders.html',
s_unassigned_students=s_unassigned_students,
s_unassigned_teachers=s_unassigned_teachers,
s_assigned=s_assigned,
topic=topic,
)
# Creation of courses
class CreateCourseForm(FlaskForm):
ident = fields.Ident("Identifier:", validators=[validators.DataRequired()])
name = fields.String("Course name:", validators=[validators.DataRequired()])
submit = wtforms.SubmitField()
return render_template('admin-topic-graders.html', s_unassigned=s_unassigned, s_assigned=s_assigned, topic=topic)
@app.route('/admin/create-course/', methods=('GET', 'POST'))
def create_course():
if not g.is_teacher_user:
raise werkzeug.exceptions.Forbidden()
sess = db.get_session()
sem = sess.scalar(select(db.Semester).order_by(db.Semester.rank.desc()).limit(1))
if not sem:
raise werkzeug.exceptions.Forbidden("No active semester")
form = CreateCourseForm()
if form.validate_on_submit():
if sess.scalar(select(db.Course).filter_by(semid=sem.semid, ident=form.ident.data)):
form.ident.errors.append('This identifier is already used.')
else:
course = db.Course(
semid=sem.semid,
ident=form.ident.data,
name=form.name.data,
enroll_token=secrets.token_hex(6),
)
sess.add(course)
enroll = db.Enroll(uid=g.uid, course=course, is_teacher=True)
sess.add(enroll)
sess.commit()
flash('New course created.', 'info')
app.logger.info(f"Created a new course #{course.cid} by #{g.uid}: semester={sem.ident} ident={course.ident} name=<{course.name}>")
return redirect(url_for('admin_course', sident=sem.ident, cident=course.ident))
return render_template(
'admin-create-course.html',
form=form,
sem=sem,
)
......@@ -321,6 +321,7 @@ def topic_to_dict(t: db.Topic):
custom_attrs = None
return {
'tid': t.tid,
'ident': t.ident,
'type': topic_type_dict.get(t.type, 'unknown'),
'name': t.title,
......@@ -330,6 +331,7 @@ def topic_to_dict(t: db.Topic):
'standalone': t.standalone,
'public': t.public,
'deadline': format_timestamp(t.deadline),
'alive': t.deadline is None or t.deadline > app.get_now(),
}
......
# Owl: Command-line interface
import click
from pathlib import Path
import secrets
from sqlalchemy import select
from sqlalchemy.orm import joinedload
......@@ -67,14 +68,16 @@ def cli_find_course(ident: str) -> db.Course:
@click.argument("last_name")
@click.argument("email")
@click.option("--token", help="Login token (default: auto-generate)")
def cli_create_user(first_name: str, last_name: str, email: str, token: Optional[str] = None) -> None:
@click.option("--admin", is_flag=True, default=False, help="Give the user administrator rights")
@click.option("--teacher", is_flag=True, default=False, help="Give the user teacher rights")
def cli_create_user(first_name: str, last_name: str, email: str, admin: bool, teacher: bool, token: Optional[str] = None) -> None:
"""Create a new account with local login."""
if not token:
token = secrets.token_hex(8)
sess = db.get_session()
user = db.User(first_name=first_name, last_name=last_name, email=email, auth_token=token)
user = db.User(first_name=first_name, last_name=last_name, email=email, auth_token=token, is_admin=admin, is_teacher=teacher)
sess.add(user)
sess.commit()
print(f'Created new user with login token {token}')
......@@ -110,7 +113,8 @@ def cli_create_course(ident: str, name: str, teacher: str, token: Optional[str]
@app.cli.command("add-teacher")
@click.argument("course_ident")
@click.argument("teacher")
def cli_add_teacher(course_ident: str, teacher: str) -> None:
@click.option("-f", "--force", is_flag=True, help="Add as a teacher even if already a student")
def cli_add_teacher(course_ident: str, teacher: str, force: bool) -> None:
"""Add a teacher to an existing course. TEACHER is the teacher's full name or e-mail.
COURSE_IDENT can be prefixed by SEMESTER/."""
......@@ -118,6 +122,16 @@ def cli_add_teacher(course_ident: str, teacher: str) -> None:
course = cli_find_course(course_ident)
sess = db.get_session()
enroll = sess.scalar(select(db.Enroll).filter_by(user=teacher_user, course=course))
if enroll is not None:
if enroll.is_teacher:
print('Already a teacher of this course, doing nothing')
elif force:
enroll.is_teacher = True
print('Converted student to a teacher')
else:
cli_die('Already a student of this course. Use "--force" to convert to a teacher.')
else:
enroll = db.Enroll(user=teacher_user, course=course, is_teacher=True)
sess.add(enroll)
sess.commit()
......@@ -210,7 +224,7 @@ def cli_fix_post_roles(dry_run: bool) -> None:
course_teachers.add((e.uid, e.cid))
topic_graders: Set[Tuple[int, int]] = set()
for g in sess.scalars(select(db.StudentGrader)):
for g in sess.scalars(select(db.Grader).filter_by(student_grader=True)):
topic_graders.add((g.uid, g.tid))
for post in posts:
......@@ -238,3 +252,38 @@ def cli_fix_post_roles(dry_run: bool) -> None:
sess.commit()
print(f'{"Would change" if dry_run else "Changed"} {changes} posts.')
@app.cli.command("fix-attachments")
@click.option("-n", "--dry-run", is_flag=True, default=False, help="Do not really perform the changes")
def cli_fix_attachments(dry_run: bool) -> None:
"""Move attachments to sub-directories after upgrading to Owl 2025-02-12."""
sess = db.get_session()
todo = []
for post in sess.scalars(select(db.Post).filter(db.Post.attachment != None).order_by(db.Post.pid)):
if '/' not in post.attachment:
todo.append(post)
for post in todo:
old_name = post.attachment
hier = old_name[:2]
base_dir = Path(app.instance_path) / 'files'
old_path = base_dir / old_name
new_path = base_dir / hier / old_name
new_name = Path(hier) / old_name
if new_path.is_file():
print(f'Post #{post.pid}: Already moved {old_path} to {new_path}')
if not dry_run:
post.attachment = str(new_name)
sess.commit()
elif not old_path.is_file():
print(f'Post #{post.pid}: {old_path} not found!')
else:
print(f'Post #{post.pid}: {"Would move" if dry_run else "Moving"} {old_path} to {new_path}')
if not dry_run:
(base_dir / hier).mkdir(exist_ok=True)
old_path.rename(new_path)
post.attachment = str(new_name)
sess.commit()
......@@ -5,7 +5,7 @@ import datetime
from decimal import Decimal
from flask import Flask, render_template, request, make_response, g, request_tearing_down, redirect, url_for, flash
from flask_wtf import FlaskForm
from sqlalchemy import select, and_, or_, false
from sqlalchemy import select, and_, or_, false, delete
import sqlalchemy.sql.functions as func
from sqlalchemy.orm import joinedload, aliased
from typing import Optional, Tuple
......@@ -16,6 +16,7 @@ import wtforms.validators as validators
from owl import app
import owl.admin
import owl.db as db
import owl.fields as fields
import owl.post
......@@ -78,9 +79,10 @@ def course_index(sident, cident):
if g.course.student_grading:
is_grader = (
select(db.StudentGrader)
.where(db.StudentGrader.tid == db.Topic.tid)
.where(db.StudentGrader.uid == g.uid)
select(db.Grader)
.where(db.Grader.tid == db.Topic.tid)
.where(db.Grader.uid == g.uid)
.where(db.Grader.student_grader == True)
.exists()
.correlate(db.Topic)
)
......@@ -146,7 +148,7 @@ def course_index(sident, cident):
# Enrolling
class EnrollTokenForm(FlaskForm):
token = wtforms.StringField("Token", validators=[validators.DataRequired()])
token = fields.String("Token", validators=[validators.DataRequired()])
class EnrollConfirmForm(FlaskForm):
......@@ -215,8 +217,43 @@ def enroll_commit(course: db.Course) -> None:
sess.commit()
# Leaving
class LeaveConfirmForm(FlaskForm):
pass
@app.route('/c/<sident>/<cident>/leave', methods=('GET', 'POST'))
def leave(sident: str, cident: str) -> str:
app.course_init(sident, cident)
sess = db.get_session()
err = None
if g.is_teacher:
err = 'Teachers are not allowed to leave their courses.'
else:
if sess.scalar(
select(db.Post)
.join(db.Topic)
.filter(or_(db.Post.target_uid == g.uid, db.Post.author_uid == g.uid))
.filter(db.Topic.course == g.course)
.limit(1)
):
err = 'You cannot leave, because you already have posts in this course.'
form = LeaveConfirmForm()
if err is None and form.validate_on_submit():
sess.execute(delete(db.Enroll).where(and_(db.Enroll.uid == g.uid, db.Enroll.course == g.course)))
sess.commit()
flash(f'You left the course {g.course.name}.', 'info')
return redirect(url_for('index'))
return render_template('leave.html', form=form, error=err)
# Topics
# Construction of this URL is hard-wired in teacher.js
@app.route('/c/<sident>/<cident>/<tident>/', methods=('GET', 'POST'))
@app.route('/c/<sident>/<cident>/<tident>/<int:student_uid>/', methods=('GET', 'POST'))
def topic_index(sident: str, cident: str, tident: str, student_uid: Optional[int] = None):
......@@ -251,7 +288,7 @@ def topic_index(sident: str, cident: str, tident: str, student_uid: Optional[int
else:
student_uid = g.uid
form = owl.post.TopicPostForm()
form = owl.post.create_post_form()
if request.method == 'POST':
if form.validate_on_submit():
......
......@@ -3,10 +3,10 @@
import datetime
from enum import Enum as PythonEnum, auto
from sqlalchemy import Boolean, CHAR, Column, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, text
from sqlalchemy.orm import relationship, Session
from sqlalchemy import Boolean, CHAR, Column, DateTime, ForeignKey, Integer, Numeric, String, Text, UniqueConstraint, text, inspect
from sqlalchemy.orm import relationship, Session, declarative_base, class_mapper
from sqlalchemy.orm.attributes import get_history
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.declarative import declarative_base
from typing import Any, List, Tuple
Base = declarative_base()
......@@ -71,6 +71,7 @@ class User(Base):
email = Column(String(255), server_default=text("NULL::character varying"))
created = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP"))
is_admin = Column(Boolean, nullable=False, server_default=text("false"))
is_teacher = Column(Boolean, nullable=False, server_default=text("false"))
notify = Column(Boolean, nullable=False, server_default=text("false"))
notify_self = Column(Boolean, nullable=False, server_default=text("false"))
notify_att = Column(Boolean, nullable=False, server_default=text("true"))
......@@ -94,8 +95,10 @@ class Course(Base):
enroll_token = Column(String(255), nullable=False, unique=True)
student_grading = Column(Boolean, nullable=False, server_default=text("false"))
anon_grading = Column(Boolean, nullable=False, server_default=text("false"))
split_grading = Column(Boolean, nullable=False, server_default=text("false"))
auto_deadline = Column(String(255))
pass_threshold = Column(Numeric(6, 2), server_default=text("NULL::numeric"))
allow_zip_att = Column(Boolean, nullable=False, server_default=text("true"))
semester = relationship('Semester')
......@@ -209,11 +212,12 @@ class Seen(Base):
topic = relationship('Topic')
class StudentGrader(Base):
__tablename__ = 'owl_student_graders'
class Grader(Base):
__tablename__ = 'owl_graders'
tid = Column(Integer, ForeignKey('owl_topics.tid', ondelete='CASCADE'), primary_key=True)
uid = Column(Integer, ForeignKey('owl_users.uid', ondelete='CASCADE'), primary_key=True)
student_grader = Column(Boolean, nullable=False, server_default=text("false"))
topic = relationship('Topic')
user = relationship('User')
......@@ -249,3 +253,29 @@ flask_db: Any = None
def get_session() -> Session:
assert flask_db is not None
return flask_db.session
def get_object_changes(obj):
""" Given a model instance, returns dict of pending changes waiting for database flush/commit.
e.g. {
'some_field': {
'before': *SOME-VALUE*,
'after': *SOME-VALUE*
},
...
}
Inspired by OSMO.
"""
inspection = inspect(obj)
changes = {}
for attr in class_mapper(obj.__class__).column_attrs:
if getattr(inspection.attrs, attr.key).history.has_changes():
if get_history(obj, attr.key)[2]:
before = get_history(obj, attr.key)[2].pop()
after = getattr(obj, attr.key)
if before != after:
if before or after:
changes[attr.key] = {'before': str(before), 'after': str(after)}
return changes