Select Git revision
template_helper.py
Forked from
Video AG Infrastruktur / website
Source project has a limited visibility.
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
import.tsx 17.90 KiB
import { useBackendContext } from "@/components/BackendProvider";
import ModeratorBarrier from "@/components/ModeratorBarrier";
import { ReloadBoundary, useReloadBoundary } from "@/components/ReloadBoundary";
import { ICalEvent, parseICal } from "@/misc/Calendar";
import { DateTime } from "luxon";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { MutableRefObject, useEffect, useRef, useState } from "react";
import { showError, showErrorMessage } from "@/misc/Util";
function TerminBody({
course,
imported_events,
}: {
course: course;
imported_events: MutableRefObject<ICalEvent[]>;
}) {
const api = useBackendContext();
const reloadFunc = useReloadBoundary();
const existingEvents: ICalEvent[] = (course.lectures ?? []).map((l) => {
const obj: ICalEvent = {
location: l.location,
duration: l.duration,
startDate: DateTime.fromISO(l.time),
foreign_id: l.id,
};
return obj;
});
const removeDuplicates = (event: ICalEvent, index: number, self: ICalEvent[]) => {
// remove duplicates
return (
index ===
self.findIndex(
(e) =>
e.location === event.location &&
e.duration === event.duration &&
Math.abs(e.startDate.diff(event.startDate).as("seconds")) < 1,
)
);
};
// find events that are new (place, duration and time unique)
const new_events = imported_events.current
.filter((event) => {
const eventMatch = existingEvents.some((existing) => {
const match =
existing.location === event.location &&
existing.duration === event.duration &&
Math.abs(existing.startDate.diff(event.startDate).as("seconds")) < 1;
return match;
});
return !eventMatch;
})
.filter(removeDuplicates);
// find existing events that are not in the imported events
const not_imported_events = existingEvents
.filter((event) => {
return !imported_events.current.some((imported) => {
return (
imported.location === event.location &&
imported.duration === event.duration &&
Math.abs(imported.startDate.diff(event.startDate).as("seconds")) < 1
);
});
})
.filter(removeDuplicates);
// number of full matches
const full_matches = imported_events.current
.filter((event) => {
return existingEvents.some((existing) => {
return (
existing.location === event.location &&
existing.duration === event.duration &&
Math.abs(existing.startDate.diff(event.startDate).as("seconds")) < 1
);
});
})
.filter(removeDuplicates).length;
const importEvent = (event: ICalEvent) => {
// time must be YYYY-MM-ddTHH:mm:ss
api.getNewOMConfiguration("lecture")
.then((res) => {
const defaultValues: any = {};
res.fields?.forEach((field) => {
if (field.may_be_null) {
return;
}
if (field.default_value !== undefined && field.default_value !== null) {
defaultValues[field.id] = field.default_value;
} else if (
field.type === "string" &&
(field.min_length === undefined || field.min_length <= 0)
) {
defaultValues[field.id] = "";
}
});
const convertedTime = event.startDate?.toFormat("yyyy-MM-dd'T'HH:mm:ss");
return api.createOMObject("lecture", {
parent_type: "course",
parent_id: course.id,
values: {
...defaultValues,
title: event.summary,
location: event.location,
time: convertedTime,
duration: event.duration,
},
});
})
.then((res) => {
reloadFunc();
})
.catch((e) => {
showError(e, "Unable to create lecture");
});
};
const importAllEvents = () => {
api.getNewOMConfiguration("lecture")
.then((res) => {
const defaultValues: any = {};
res.fields?.forEach((field) => {
if (field.may_be_null) {
return;
}
if (field.default_value !== undefined && field.default_value !== null) {
defaultValues[field.id] = field.default_value;
} else if (
field.type === "string" &&
(field.min_length === undefined || field.min_length <= 0)
) {
defaultValues[field.id] = "";
}
});
return Promise.allSettled(
new_events.map((event) => {
const convertedTime = event.startDate?.toFormat("yyyy-MM-dd'T'HH:mm:ss");
return api.createOMObject("lecture", {
parent_type: "course",
parent_id: course.id,
values: {
...defaultValues,
title: event.summary,
location: event.location,
time: convertedTime,
duration: event.duration,
},
});
}),
);
})
.then((res) => {
reloadFunc();
})
.catch((e) => {
showError(e, "Unable to create lectures");
reloadFunc();
});
};
const removeEvent = (event: ICalEvent) => {
if (!confirm("Really delete?")) return;
if (event.foreign_id === undefined) {
showErrorMessage("Event not found");
return;
}
api.deleteOMObject("lecture", event.foreign_id)
.then((res) => {
reloadFunc();
})
.catch((e) => {
showError(e, "Unable to delete lecture");
});
};
const removeAllEvents = () => {
if (!confirm("Really delete all?")) return;
const promises = existingEvents.flatMap((event) => {
if (event.foreign_id === undefined) {
return [];
}
return [api.deleteOMObject("lecture", event.foreign_id)];
});
Promise.allSettled(promises)
.then((res) => {
reloadFunc();
})
.catch((e) => {
showError(e, "Unable to delete lectures");
reloadFunc();
});
};
return (
<div>
<table className="table table-bordered w-100 table-sm text-center">
<thead>
<tr>
<th scope="col">Nur im Import</th>
<th scope="col">In beiden</th>
<th scope="col">Nur bei uns</th>
</tr>
</thead>
<tbody>
<tr>
<td className="text-warning">{new_events.length}</td>
<td className="text-success">{full_matches}</td>
<td className="text-warning">{not_imported_events.length}</td>
</tr>
</tbody>
</table>
<div className="card mb-2">
<div className="card-header d-flex align-items-center">
<span className="flex-grow-1">Im Import, nicht bei uns</span>
<button className="btn btn-primary" onClick={importAllEvents}>
Alle anlegen
</button>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>Zeit</th>
<th>Ort</th>
<th>Dauer</th>
<th></th>
</tr>
</thead>
<tbody>
{new_events.map((event, index) => (
<tr key={index}>
<td>
{event.startDate
? event.startDate.toFormat("yyyy-MM-dd HH:mm:ss")
: "kein datum"}
</td>
<td>{event.location}</td>
<td>{event.duration}</td>
<td>
<button
className="btn btn-primary"
onClick={() => importEvent(event)}
>
Anlegen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header d-flex align-items-center">
<span className="flex-grow-1">Bei uns, nicht im Import</span>
<button className="btn btn-primary" onClick={removeAllEvents}>
Alle entfernen
</button>
</div>
<div className="card-body">
<table className="table">
<thead>
<tr>
<th>Zeit</th>
<th>Ort</th>
<th>Dauer</th>
<th></th>
</tr>
</thead>
<tbody>
{not_imported_events.map((event, index) => (
<tr key={index}>
<td>
{event.startDate
? event.startDate.toFormat("yyyy-MM-dd HH:mm:ss")
: "kein datum"}
</td>
<td>{event.location}</td>
<td>{event.duration}</td>
<td>
<button
className="btn btn-primary"
onClick={() => removeEvent(event)}
>
Entfernen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
function RWTHOnlineImportImpl() {
const api = useBackendContext();
const searchParam = useSearchParams();
const course_id = searchParam.get("course_id");
const [course, setCourse] = useState<course>();
const imported_events = useRef<ICalEvent[]>([]);
const imported_courses = useRef<number[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const [reload, setReload] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const exampleImportUrl =
"https://online.rwth-aachen.de/RWTHonline/pl/ui/%24ctx/wbTvw_List.lehrveranstaltung?pStpSpNr=...";
useEffect(() => {
if (course_id === null) return;
api.getCourse(course_id, true).then((res) => {
setCourse(res);
});
}, [course_id, api, reload]);
if (course_id === null) {
return (
<div>
<p>Invalid ?course_id</p>
</div>
);
}
if (course === undefined) {
return (
<div>
<p>Loading...</p>
</div>
);
}
const onImport = () => {
if (inputRef.current === null) return;
const url = inputRef.current.value;
let courseIdNr: number | undefined;
if (url.includes("pStpSpNr=")) {
courseIdNr = parseInt(url.split("pStpSpNr=")[1].split("&")[0]);
} else {
// https://online.rwth-aachen.de/RWTHonline/ee/ui/ca2/app/desktop/#/slc.tm.cp/student/courses/487373?$ctx=lang=de&$scrollTo=toc_overview
const regex = /online\.rwth-aachen\.de\/.*\/courses\/(\d+)/;
const match = url.match(regex);
if (match) {
courseIdNr = parseInt(match[1]);
} else {
showErrorMessage(
`The provided URL is invalid. Must be of format: ${exampleImportUrl}`,
);
return;
}
}
if (courseIdNr === undefined) {
showErrorMessage(`The provided URL is invalid. Must be of format: ${exampleImportUrl}`);
return;
}
if (imported_courses.current.includes(courseIdNr)) {
showErrorMessage(e, "The specified course is already in the import");
return;
}
setIsImporting(true);
api.importRWTHOnlineCourse(courseIdNr)
.then((ical) => {
imported_courses.current.push(courseIdNr!);
const parsed = parseICal(ical);
// add id to events
parsed.forEach((event) => {
event.foreign_id = courseIdNr;
});
// append
imported_events.current = imported_events.current.concat(parsed);
setIsImporting(false);
})
.catch((e) => {
showError(e, "Unable to import lectures from RWTHonline");
setIsImporting(false);
});
};
const removeCourse = (courseId: number) => {
if (!confirm("Wirklich löschen?")) return;
imported_courses.current = imported_courses.current.filter((id) => id !== courseId);
imported_events.current = imported_events.current.filter(
(event) => event.foreign_id !== courseId,
);
setReload(!reload);
};
return (
<ReloadBoundary reloadFunc={() => setReload(!reload)}>
<Link href={`/${course.id_string}`} className="btn btn-primary mb-2">
<span className="bi bi-chevron-left" /> Zum Kurs
</Link>
<div className="card mb-2">
<div className="card-header d-flex">Import von RWTHOnline</div>
<div className="card-body">
<a
href="https://online.rwth-aachen.de/RWTHonline/pl/ui/%24ctx/wbSuche.LVSuche"
target="_blank"
>
Suche den Kurs auf RWTHOnline
</a>
, und kopiere die URL hier rein:
<div>
<input
type="text"
className="w-100"
placeholder={exampleImportUrl}
ref={inputRef}
/>
{isImporting ? (
<div className="spinner-border mt-2" role="status" />
) : (
<button
className="btn btn-primary mt-2"
type="button"
onClick={onImport}
>
Zum Import hinzufügen
</button>
)}
</div>
<div>
Bereits importierte Termine: {imported_events.current.length}
<table className="table table-bordered">
<tbody>
{imported_courses.current.map((courseId, index) => (
<tr key={index}>
<td>
{courseId}
<button
type="button"
className="ms-2 btn btn-danger"
onClick={() => removeCourse(courseId)}
>
<i className="bi bi-trash-fill"></i>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<TerminBody course={course} imported_events={imported_events} />
</ReloadBoundary>
);
}
export default function RWTHOnlineImport() {
return (
<ModeratorBarrier>
<RWTHOnlineImportImpl />
</ModeratorBarrier>
);
}