diff --git a/Dockerfile b/Dockerfile index 80fd832..4298426 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,10 @@ RUN mkdir /state && \ COPY --chown=spz:spz uwsgi.ini uwsgi.ini COPY --chown=spz:spz src/spz spz +# create files directory (save excel files) +RUN mkdir /home/spz/code/spz/files && \ + chown -R spz:spz /home/spz/code/spz/files + # switch to spz user USER 1000 diff --git a/src/spz/__init__.py b/src/spz/__init__.py index 6173b4e..3b5a538 100644 --- a/src/spz/__init__.py +++ b/src/spz/__init__.py @@ -208,6 +208,8 @@ def rlrc_comment(): ('/internal/grades//edit', admin_views.edit_grade, ['GET', 'POST']), ('/internal/grades//edit_view', admin_views.edit_grade_view, ['GET', 'POST']), ('/internal/grades//import_grade', admin_views.import_grade, ['GET', 'POST']), + ('/internal/delete_sheet/', admin_views.delete_sheet, ['GET', 'POST']), + ('/internal/download_sheet/', admin_views.download_sheet, ['GET']), ('/internal/teacher//attendance/', admin_views.attendances, ['GET', 'POST']), ('/internal/teacher//attendance//edit/', admin_views.edit_attendances, ['GET', 'POST']), diff --git a/src/spz/administration/admin_views.py b/src/spz/administration/admin_views.py index 1375973..5853cc1 100644 --- a/src/spz/administration/admin_views.py +++ b/src/spz/administration/admin_views.py @@ -5,10 +5,12 @@ Manages the mapping between routes and their activities for the administrators. """ import json +import os import socket from flask import request, redirect, render_template, url_for, flash, jsonify, make_response from flask_login import current_user, login_required, login_user, logout_user from flask_mail import Message +from sphinx.cmd.quickstart import suffix from spz import app from spz import models, db, log @@ -313,7 +315,7 @@ def edit_teacher(id): def teacher(): return dict(user=current_user) - +@login_required @templated('internal/administration/grade.html') def grade(course_id): course = models.Course.query.get_or_404(course_id) @@ -322,7 +324,7 @@ def grade(course_id): return dict(course=course, exam_date=exam_date) - +@login_required @templated('internal/administration/edit_grade.html') def edit_grade(course_id): course = models.Course.query.get_or_404(course_id) @@ -365,7 +367,7 @@ def edit_grade(course_id): return dict(course=course, form=form, exam_date=exam_date) - +@login_required @templated('internal/administration/edit_grade_view.html') def edit_grade_view(course_id): course = models.Course.query.get_or_404(course_id) @@ -397,6 +399,7 @@ def edit_grade_view(course_id): return dict(course=course, exam_date=exam_date) +@login_required @templated('internal/administration/import_grade.html') def import_grade(course_id): course = models.Course.query.get_or_404(course_id) @@ -404,15 +407,82 @@ def import_grade(course_id): if form.validate_on_submit(): try: file = form.file.data - # Save the file securely in the specified upload folder - #filepath = os.path.join(app.config['UPLOAD_FOLDER'], file.filename) - #file.save(filepath) - flash("File uploaded successfully!", "success") + + # ToDo: sanity check for valid grade sheet file + + # ToDo: grade import + + # first add the db entry to map to the file + suffix = file.filename.split(".")[-1] # only '.xls' or '.xlsx' files pass the form validation + file_increment = len(course.grade_sheets) + 1 + course_name = course.full_name.replace(" ", "_").replace("/", "_") + filename = course_name + "_version" + str(file_increment) + "." + suffix + + # in case there have been file deletions, a not used filename is chosen + while os.path.exists(os.path.join(app.config['FILE_DIR'], filename)): + file_increment += 1 + filename = course_name + "_version" + str(file_increment) + "." + suffix + + file_entry = models.GradeSheets( + course_id=course.id, + user_id=current_user.id, + filename=filename + ) + db.session.add(file_entry) + + # now save the file to the docker file volume + file.save(file_entry.dir) + flash(f"saved under: '{file_entry.dir}'", "info") + + db.session.commit() + flash("Notenliste wurde erfolgreich hochgeladen!", "success") return redirect(url_for('grade', course_id=course.id)) except Exception as e: + db.session.rollback() flash(_('Noten konnten nicht importiert werden: %(error)s', error=e), 'negative') return dict(form=form, course=course) +@login_required +def download_sheet(file_id): + file = models.GradeSheets.query.get_or_404(file_id) + + if not os.path.exists(file.dir): + flash(_('Die Datei existiert nicht oder wurde entfernt.'), 'negative') + return redirect(url_for('grade', course_id=file.course_id)) + + try: + with open(file.dir, 'rb') as f: + response = make_response(f.read()) + response.headers['Content-Disposition'] = f'attachment; filename={file.filename}' + response.headers['Content-Type'] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + return response + except IOError as e: + flash(_('Datei konnte nicht heruntergeladen werden: %(error)s', error=str(e)), 'negative') + return redirect(url_for('grade', course_id=file.course_id)) + +@login_required +@templated('internal/administration/delete_grade_sheet.html') +def delete_sheet(file_id): + file = models.GradeSheets.query.get_or_404(file_id) + + if not os.path.exists(file.dir): + flash(_('Die Datei existiert nicht oder wurde entfernt.'), 'negative') + return redirect(url_for('grade', course_id=file.course_id)) + + if request.method == 'POST': + try: + os.remove(file.dir) + db.session.delete(file) + db.session.commit() + flash(_('Datei erfolgreich gelöscht.'), 'success') + except Exception as e: + db.session.rollback() + flash(_('Datei konnte nicht gelöscht werden: %(error)s', error=e), 'negative') + return redirect(url_for('grade', course_id=file.course_id)) + + return dict(file=file) + + @templated('internal/administration/attendances.html') def attendances(id, course_id): diff --git a/src/spz/models.py b/src/spz/models.py index ed2d136..eb62886 100644 --- a/src/spz/models.py +++ b/src/spz/models.py @@ -4,10 +4,11 @@ Manages the mapping between abstract entities and concrete database models. """ -import enum +import os from enum import Enum from binascii import hexlify from datetime import datetime, timedelta, timezone +import pytz from functools import total_ordering import random import string @@ -1268,7 +1269,7 @@ def __init__(self, state, code_verifier): self.is_student = False class GradeSheets(db.Model): - """Database model for the xls/xlsx grade sheets + """Database model for the xls/xlsx grade sheet mapping :param id: unique ID :param course_id: course ID @@ -1277,24 +1278,32 @@ class GradeSheets(db.Model): :param upload_at: timestamp of the upload in GMT """ - __tablename__ = 'gradesheets' + __tablename__ = 'grade_sheets' id = db.Column(db.Integer, primary_key=True) course_id = db.Column(db.Integer, db.ForeignKey('course.id')) - dir = db.Column(db.String(100), nullable=False) - filename = db.Column(db.String(40), nullable=False) - upload_at = db.Column(db.DateTime(), default=datetime.now(timezone.utc).replace(tzinfo=None)) + user_id = db.Column(db.Integer, db.ForeignKey('user.id')) + filename = db.Column(db.String(60), nullable=False) + upload_at = db.Column(db.DateTime(), default=lambda: datetime.now(timezone.utc).replace(tzinfo=None)) - def __init__(self, course_id, dir, filename): + def __init__(self, course_id, user_id, filename): self.course_id = course_id - self.dir = dir + self.user_id = user_id self.filename = filename def __repr__(self): return '' % self.filename @property - def path(self): - return "{0}{1}".format(self.dir, self.filename) + def dir(self): + return os.path.join(app.config['FILE_DIR'], self.filename) + + def get_user(self): + return User.query.get(self.user_id) + + @property + def uploat_at_utc(self): + target_timezone = pytz.timezone("Europe/Berlin") + return self.upload_at.astimezone(target_timezone).strftime("%d.%m.%Y %H:%M") diff --git a/src/spz/templates/internal/administration/delete_grade_sheet.html b/src/spz/templates/internal/administration/delete_grade_sheet.html new file mode 100644 index 0000000..17c03bc --- /dev/null +++ b/src/spz/templates/internal/administration/delete_grade_sheet.html @@ -0,0 +1,28 @@ +{% extends 'internal/internal.html' %} +{% from 'formhelpers.html' import csrf_field, render_submit %} + +{% block caption %} + Datei Löschen: {{ file.filename }} +{% endblock caption %} + + +{% block internal_body %} +
+

{{ file.filename }} Endgültig Löschen

+
+

Soll die Datei {{ file.filename }} wirklich gelöscht werden? +
Das kann nicht rückgängig gemacht werden.

+
+
+ {{ csrf_field() }} + +
+ + +
+ + Abbrechen +
+
+
+{% endblock internal_body %} diff --git a/src/spz/templates/internal/administration/grade.html b/src/spz/templates/internal/administration/grade.html index 351f803..9bae066 100644 --- a/src/spz/templates/internal/administration/grade.html +++ b/src/spz/templates/internal/administration/grade.html @@ -8,78 +8,124 @@ {% block internal_body %} - {% if current_user.is_teacher %} -
- - - +
+ {% if current_user.is_teacher %} + + {% endif %} +
+

Notenübersicht

- {% endif %} -
-

Notenübersicht

-
-
- - -
- - - - - - - - - - - - - {% for applicant in course.course_list %} - {% set attendance = course.get_course_attendance(course.id, applicant.id) %} - - - - - - + + + {% endfor %} + +
Matrikelnr.VornameNachnameECTSNotePrüfungsdatum
{% if applicant.tag %} {{ applicant.tag }} {% endif %}{{ applicant.first_name }}{{ applicant.last_name }} - {% if attendance is none %} - Fehler: nicht in Liste - {% else %} - {{ attendance.ects_points }} - {% endif %} - - {% if attendance is none %} - Fehler: nicht in Liste - {% else %} - {% if attendance.grade is not none %} - {% if attendance.hide_grade %} - bestanden +
+ + + + + + + + + + + + + {% for applicant in course.course_list %} + {% set attendance = course.get_course_attendance(course.id, applicant.id) %} + + + + + + - - - {% endfor %} - -
Matrikelnr.VornameNachnameECTSNotePrüfungsdatum
{% if applicant.tag %} {{ applicant.tag }} {% endif %}{{ applicant.first_name }}{{ applicant.last_name }} + {% if attendance is none %} + Fehler: nicht in Liste + {% else %} + {{ attendance.ects_points }} + {% endif %} + + {% if attendance is none %} + Fehler: nicht in Liste + {% else %} + {% if attendance.grade is not none %} + {% if attendance.hide_grade %} + bestanden + {% else %} + {{ attendance.full_grade }} + {% endif %} {% else %} - {{ attendance.full_grade }} + - {% endif %} - {% else %} - - {% endif %} - {% endif %} - {{ exam_date }}
-
-
-

Hochgeladene Notenliste

-

Es wurde bisher keine Datei hochgeladen

+
{{ exam_date }}
+
+
+

Hochgeladene Notenliste(n)

+ {% if course.grade_sheets|length < 1 %} +

Es wurde bisher keine Datei hochgeladen

+ {% else %} +
+ + + + + + + {% if current_user.is_admin_or_superuser %} + + {% endif %} + + + + + {% for file in course.grade_sheets %} + + + + + {% if current_user.is_admin_or_superuser %} + + {% endif %} + + + {% endfor %} + +
DateiHochgeladen vonDownloadLöschenDatum
{{ file.filename }}{{ file.get_user().full_name }} + + + + + + + + {{ file.uploat_at_utc }}
+
+ {% endif %} +
+
diff --git a/src/spz/templates/internal/administration/import_grade.html b/src/spz/templates/internal/administration/import_grade.html index c4ee1b2..df3e3ec 100644 --- a/src/spz/templates/internal/administration/import_grade.html +++ b/src/spz/templates/internal/administration/import_grade.html @@ -8,6 +8,11 @@ {% block internal_body %} +

Notenliste Hochladen

@@ -15,7 +20,8 @@

Notenliste Hochladen

{{ csrf_field() }}
-

Notenliste für {{ course.language.name }} {{ course.level }} mit ausgefüllten Noten für den Upload auswählen.

+

Notenliste für {{ course.language.name }} {{ course.level }} mit ausgefüllten Noten für + den Upload auswählen.

{{ form.file(class="ui input") }}