Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: JSON enrollment importer #2187

Draft
wants to merge 25 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
eb2b222
First steps with new JSON importer
hansegucker May 6, 2024
115d19e
[JSON import] Import lecturers
hansegucker May 6, 2024
814dbee
[JSON importer] Import courses and evaluations
hansegucker May 13, 2024
8508080
Use unique attribute for cms_id
hansegucker May 13, 2024
66e8f48
Clean up JSON import
hansegucker May 13, 2024
e4b0300
Import contributions from JSON
hansegucker May 13, 2024
cd7d40f
[JSON import] Don't import data for evaluations in approved state
hansegucker May 27, 2024
34b20dd
[JSON import] Create statistics during the import
hansegucker Jun 10, 2024
4914f98
[JSON import] Add log handler for email sending
hansegucker Jun 17, 2024
a886322
[JSON import] Add management command for import
hansegucker Jun 17, 2024
a6c8309
Fix log_exceptions to correctly pass args to handle
hansegucker Jun 17, 2024
0ce1464
Improve JSON importer code
hansegucker Jun 24, 2024
cc0f045
Refactor and optimize JSON importer
hansegucker Jul 1, 2024
5d2b213
Test management command for JSON import
hansegucker Jul 1, 2024
5ab1375
Remove test_data.json
hansegucker Jul 1, 2024
a8793e1
Fix problems with JSON importer tests
hansegucker Jul 1, 2024
f2da64c
[JSON importer] Also create name changes for lecturer changes
hansegucker Jul 8, 2024
2c23d53
Fix some code style issues
hansegucker Jul 29, 2024
2b3fa2c
Add cms_id to excluded fields in copy form
hansegucker Jul 29, 2024
315f7b2
Fix headings in JSON importer
hansegucker Aug 5, 2024
55a794c
Merge branch 'main' into json-enrollment-importer
hansegucker Oct 7, 2024
73fb0f7
Fix migrations and model names after merge (Degree to Program)
hansegucker Oct 7, 2024
e4d0ccc
[JSON importer] Send useful log email
hansegucker Oct 7, 2024
af64f84
Improve code style in JSON importer (tests)
hansegucker Oct 7, 2024
e90acf7
Merge branch 'main' into json-enrollment-importer
hansegucker Oct 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions evap/evaluation/migrations/0143_course_cms_id_evaluation_cms_id.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Generated by Django 5.0.4 on 2024-05-13 20:59

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("evaluation", "0142_alter_evaluation_state"),
]

operations = [
migrations.AddField(
model_name="course",
name="cms_id",
field=models.CharField(
blank=True, max_length=255, null=True, unique=True, verbose_name="campus management system id"
),
),
migrations.AddField(
model_name="evaluation",
name="cms_id",
field=models.CharField(
blank=True, max_length=255, null=True, unique=True, verbose_name="campus management system id"
),
),
]
10 changes: 10 additions & 0 deletions evap/evaluation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,11 @@ class Course(LoggedModel):
# grade publishers can set this to True, then the course will be handled as if final grades have already been uploaded
gets_no_grade_documents = models.BooleanField(verbose_name=_("gets no grade documents"), default=False)

# unique reference for import from campus management system
cms_id = models.CharField(
verbose_name=_("campus management system id"), blank=True, null=True, unique=True, max_length=255
)

class Meta:
unique_together = [
["semester", "name_de"],
Expand Down Expand Up @@ -444,6 +449,11 @@ class State:
verbose_name=_("wait for grade upload before publishing"), default=True
)

# unique reference for import from campus management system
cms_id = models.CharField(
verbose_name=_("campus management system id"), blank=True, null=True, unique=True, max_length=255
)

class TextAnswerReviewState(Enum):
do_not_call_in_templates = True # pylint: disable=invalid-name
NO_TEXTANSWERS = auto()
Expand Down
204 changes: 204 additions & 0 deletions evap/staff/importers/json.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
from datetime import datetime, timedelta
from typing import TypedDict

from django.db import transaction

from evap.evaluation.models import Contribution, Course, CourseType, Degree, Evaluation, Semester, UserProfile
from evap.evaluation.tools import clean_email


class ImportStudent(TypedDict):
gguid: str
email: str
name: str
christianname: str


class ImportLecturer(TypedDict):
gguid: str
email: str
name: str
christianname: str
titlefront: str


class ImportCourse(TypedDict):
cprid: str
scale: str


class ImportRelated(TypedDict):
gguid: str


class ImportAppointment(TypedDict):
begin: str
end: str


class ImportEvent(TypedDict):
gguid: str
lvnr: int
title: str
title_en: str
type: str
isexam: bool
courses: list[ImportCourse]
relatedevents: ImportRelated
appointments: list[ImportAppointment]
lecturers: list[ImportRelated]
students: list[ImportRelated]


class ImportDict(TypedDict):
students: list[ImportStudent]
lecturers: list[ImportLecturer]
events: list[ImportEvent]


class JSONImporter:
DATETIME_FORMAT = "%d.%m.%Y %H:%M"

def __init__(self, semester: Semester):
hansegucker marked this conversation as resolved.
Show resolved Hide resolved
self.semester = semester
self.user_profile_map: dict[str, UserProfile] = {}
self.course_type_cache: dict[str, CourseType] = {}
self.degree_cache: dict[str, Degree] = {}
self.course_map: dict[str, Course] = {}

def _get_course_type(self, name: str) -> CourseType:
if name in self.course_type_cache:
return self.course_type_cache[name]

course_type = CourseType.objects.get_or_create(name_de=name, defaults={"name_en": name})[0]
hansegucker marked this conversation as resolved.
Show resolved Hide resolved
self.course_type_cache[name] = course_type
return course_type

def _get_degree(self, name: str) -> Degree:
if name in self.degree_cache:
return self.degree_cache[name]

degree = Degree.objects.get_or_create(name_de=name, defaults={"name_en": name})[0]
self.degree_cache[name] = degree
return degree
hansegucker marked this conversation as resolved.
Show resolved Hide resolved

def _get_user_profiles(self, data: list[ImportRelated]) -> list[UserProfile]:
return [self.user_profile_map[related["gguid"]] for related in data]

def _import_students(self, data: list[ImportStudent]):
for entry in data:
email = clean_email(entry["email"])
user_profile, __ = UserProfile.objects.update_or_create(
email=email,
defaults={"last_name": entry["name"], "first_name_given": entry["christianname"]},
)

self.user_profile_map[entry["gguid"]] = user_profile

def _import_lecturers(self, data: list[ImportLecturer]):
for entry in data:
email = clean_email(entry["email"])
user_profile, __ = UserProfile.objects.update_or_create(
email=email,
defaults={
"last_name": entry["name"],
"first_name_given": entry["christianname"],
"title": entry["titlefront"],
hansegucker marked this conversation as resolved.
Show resolved Hide resolved
},
)

self.user_profile_map[entry["gguid"]] = user_profile

def _import_course(self, data: ImportEvent) -> Course:
course_type = self._get_course_type(data["type"])
degrees = [self._get_degree(c["cprid"]) for c in data["courses"]]
responsibles = self._get_user_profiles(data["lecturers"])
course, __ = Course.objects.update_or_create(
semester=self.semester,
cms_id=data["gguid"],
defaults={"name_de": data["title"], "name_en": data["title_en"], "type": course_type},
)
course.degrees.set(degrees)
course.responsibles.set(responsibles)

self.course_map[data["gguid"]] = course

return course

def _import_evaluation(self, course: Course, data: ImportEvent) -> Evaluation:
course_end = datetime.strptime(data["appointments"][0]["end"], self.DATETIME_FORMAT)

if data["isexam"]:
# Set evaluation time frame of three days for exam evaluations:
evaluation_start_datetime = course_end.replace(hour=8, minute=0) + timedelta(days=1)
hansegucker marked this conversation as resolved.
Show resolved Hide resolved
evaluation_end_date = (course_end + timedelta(days=3)).date()

name_de = "Klausur"
name_en = "Exam"
else:
# Set evaluation time frame of two weeks for normal evaluations:
# Start datetime is at 8:00 am on the monday in the week before the event ends
evaluation_start_datetime = course_end.replace(hour=8, minute=0) - timedelta(
weeks=1, days=course_end.weekday()
)
# End date is on the sunday in the week the event ends
evaluation_end_date = (course_end + timedelta(days=6 - course_end.weekday())).date()

name_de, name_en = "", ""

# If events are graded for any degree, wait for grade upload before publishing
wait_for_grade_upload_before_publishing = any(filter(lambda grade: grade["scale"], data["courses"]))
hansegucker marked this conversation as resolved.
Show resolved Hide resolved

participants = self._get_user_profiles(data["students"])

evaluation, __ = Evaluation.objects.update_or_create(
course=course,
cms_id=data["gguid"],
defaults={
"name_de": name_de,
"name_en": name_en,
"vote_start_datetime": evaluation_start_datetime,
"vote_end_date": evaluation_end_date,
"wait_for_grade_upload_before_publishing": wait_for_grade_upload_before_publishing,
},
)
evaluation.participants.set(participants)

for lecturer in data["lecturers"]:
self._import_contribution(evaluation, lecturer)

return evaluation

def _import_contribution(self, evaluation: Evaluation, data: ImportRelated):
user_profile = self.user_profile_map[data["gguid"]]

contribution, __ = Contribution.objects.update_or_create(
hansegucker marked this conversation as resolved.
Show resolved Hide resolved
evaluation=evaluation,
contributor=user_profile,
)
return contribution

def _import_events(self, data: list[ImportEvent]):
# Divide in two lists so corresponding courses are imported before their exams
normal_events = filter(lambda e: not e["isexam"], data)
exam_events = filter(lambda e: e["isexam"], data)
hansegucker marked this conversation as resolved.
Show resolved Hide resolved

for event in normal_events:
event: ImportEvent
hansegucker marked this conversation as resolved.
Show resolved Hide resolved

course = self._import_course(event)

self._import_evaluation(course, event)

for event in exam_events:
event: ImportEvent

course = self.course_map[event["relatedevents"]["gguid"]]

self._import_evaluation(course, event)

@transaction.atomic
def import_json(self, data: ImportDict):
self._import_students(data["students"])
self._import_lecturers(data["lecturers"])
self._import_events(data["events"])
hansegucker marked this conversation as resolved.
Show resolved Hide resolved
Loading