diff options
| author | Moritz Strohm <strohm@data-quest.de> | 2024-09-02 07:50:49 +0000 |
|---|---|---|
| committer | Moritz Strohm <strohm@data-quest.de> | 2024-09-02 07:50:49 +0000 |
| commit | f00164f6f8b823872d0934830a466aeb2af7114b (patch) | |
| tree | e810d56f7ad1ec8f1e1dd17affd0954f5a54aaf0 /lib/models/Course.php | |
| parent | afadde64a6a2017eabb36a3bdef412bb2d2692ba (diff) | |
StEP 3209, re #3209
Merge request studip/studip!2179
Diffstat (limited to 'lib/models/Course.php')
| -rw-r--r-- | lib/models/Course.php | 1358 |
1 files changed, 1348 insertions, 10 deletions
diff --git a/lib/models/Course.php b/lib/models/Course.php index d8bb2f5..ea6676c 100644 --- a/lib/models/Course.php +++ b/lib/models/Course.php @@ -144,6 +144,24 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe 'on_delete' => 'delete', 'on_store' => 'store', ]; + $config['has_many']['scm_entries'] = [ + 'class_name' => StudipScmEntry::class, + 'assoc_foreign_key' => 'range_id', + 'on_delete' => 'delete', + 'on_store' => 'store' + ]; + $config['has_many']['wiki_pages'] = [ + 'class_name' => WikiPage::class, + 'assoc_foreign_key' => 'range_id', + 'on_delete' => 'delete', + 'on_store' => 'store' + ]; + $config['has_many']['news'] = [ + 'class_name' => StudipNews::class, + 'thru_table' => 'news_range', + 'thru_key' => 'range_id', + 'thru_assoc_key' => 'news_id', + ]; $config['has_many']['blubberthreads'] = [ 'class_name' => BlubberThread::class, 'assoc_func' => 'findBySeminar', @@ -182,10 +200,12 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe 'on_store' => 'store', ]; $config['has_and_belongs_to_many']['institutes'] = [ - 'class_name' => Institute::class, - 'thru_table' => 'seminar_inst', - 'on_delete' => 'delete', - 'on_store' => 'store', + 'class_name' => Institute::class, + 'thru_table' => 'seminar_inst', + 'thru_key' => 'seminar_id', + 'thru_assoc_key' => 'institut_id', + 'on_delete' => 'delete', + 'on_store' => 'store', ]; $config['has_and_belongs_to_many']['domains'] = [ @@ -201,6 +221,11 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe 'assoc_foreign_key' => 'course_id', 'on_delete' => 'delete', ]; + $config['has_many']['resource_bookings'] = [ + 'class_name' => ResourceBooking::class, + 'assoc_foreign_key' => 'range_id', + 'on_delete' => 'delete' + ]; $config['belongs_to']['parent'] = [ 'class_name' => Course::class, 'foreign_key' => 'parent_course' @@ -221,6 +246,13 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe 'on_delete' => 'delete', ]; + $config['has_many']['config_values'] = [ + 'class_name' => ConfigValue::class, + 'assoc_foreign_key' => 'range_id', + 'on_store' => 'store', + 'on_delete' => 'delete' + ]; + $config['has_many']['courseware_units'] = [ 'class_name' => \Courseware\Unit::class, 'assoc_foreign_key' => 'range_id', @@ -278,10 +310,70 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe "UPDATE `seminare` SET `parent_course` = NULL WHERE `parent_course` = :course", ['course' => $course->id] ); - DBManager::get()->execute( - "DELETE FROM `forum_visits` WHERE `seminar_id` = ?", - [$course->id] - ); + + //Delete forum entries: + foreach (PluginEngine::getPlugins(ForumModule::class) as $forum_tool) { + $forum_tool->deleteContents($course->id); + } + + //Delete all files: + $folder = Folder::findTopFolder($course->id); + if ($folder) { + $folder->delete(); + } + + //Unlink all news and delete them in RSS feeds: + StudipNews::DeleteNewsRanges($course->id); + StudipNews::UnsetRssId($course->id); + + //Cleanup remaining wiki table entries: + $query = 'DELETE FROM `wiki_links` WHERE `range_id` = ?'; + $statement = DBManager::get()->execute($query, [$course->id]); + $query = 'DELETE FROM `wiki_locks` WHERE `range_id` = ?'; + $statement = DBManager::get()->execute($query, [$course->id]); + WikiPageConfig::deleteByRange_id($course->id); + + //Remove all entries of the course in calendars: + $query = 'DELETE FROM `schedule_seminare` WHERE `seminar_id` = ?'; + $statement = DBManager::get()->execute($query, [$course->id]); + + //Remove connections to other e-learning systems: + if (Config::get()->ELEARNING_INTERFACE_ENABLE) { + $cms_types = ObjectConnections::GetConnectedSystems($course->id); + foreach ($cms_types as $system) { + if (empty($GLOBALS['connected_cms'][$system])) { + continue; + } + ELearningUtils::loadClass($system); + $del_cms += $GLOBALS['connected_cms'][$system]->deleteConnectedModules($course->id); + } + } + + //Remove all entries in object_user_vists for the course: + object_kill_visits(null, $course->id); + + //Remove deputies: + Deputy::deleteByRange_id($course->id); + + //Remove user domains: + UserDomain::removeUserDomainsForSeminar($course->id); + + //Remove auto-insert entries: + AutoInsert::deleteSeminar($course->id); + + //Remove assignments to admission sets: + $cs = $this->getCourseSet(); + if ($cs) { + CourseSet::removeCourseFromSet($cs->getId(), $course->id); + $cs->load(); + if (!count($cs->getCourses()) && $cs->isGlobal() && $cs->getUserid() != '') { + $cs->delete(); + } + } + AdmissionPriority::unsetAllPrioritiesForCourse($course->id); + + //Create a log entry: + StudipLog::log('SEM_ARCHIVE', $course->id, NULL, $course->getFullName('number-name-semester')); }; parent::configure($config); @@ -487,7 +579,7 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe }); } - public function getFreeSeats() + public function getFreeSeats() : int { $free_seats = $this->admission_turnout - $this->getNumParticipants(); return max($free_seats, 0); @@ -507,6 +599,327 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe } /** + * Determines whether the course has at least one course set attached to it. + * + * @return bool True, if the course has at least one course set, false otherwise. + */ + public function hasCourseSet() : bool + { + return CourseSet::countBySeminar_id($this->id) > 0; + } + + /** + * Retrieves the course set of th course, if the course is associated to a course set. + * + * @return CourseSet|null The course set of the course, if it is associated to one. + */ + public function getCourseSet() : ?CourseSet + { + return CourseSet::getSetForCourse($this->id); + } + + /** + * Determines whether the number of participants in this course is limited + * by a course set whose seat distribution is enabled. + * + * @return boolean True, if a course set exists and its seat distribution is enabled, + * false otherwise. + */ + public function isAdmissionEnabled() : bool + { + $cs = $this->getCourseSet(); + return $cs && $cs->isSeatDistributionEnabled(); + } + + /** + * Determines by the course set of the course (if any), whether the admission + * is locked or not. + * + * @return bool True, if the admission is locked, false otherwise. + */ + public function isAdmissionLocked() : bool + { + $cs = $this->getCourseSet(); + return $cs && $cs->hasAdmissionRule('LockedAdmission'); + } + + /** + * Determines by looking at the course set (if any), whether the course + * is password protected or not. + * + * @return bool True, fi the course is password protected, false otherwise. + */ + public function isPasswordProtected() : bool + { + $cs = $this->getCourseSet(); + return $cs && $cs->hasAdmissionRule('PasswordAdmission'); + } + + /** + * Determines if there is an admission time frame for this course by looking + * at the course set (if any). If such a time frame exists, it is returned + * as an associative array with the start and end timestamp. + * + * @returns array An associative array with the array keys "start_time" and "end_time" + * containing the start and end timestamp of the admission. In case no such time + * frame exists, an empty array is returned instead. + */ + public function getAdmissionTimeFrame() : array + { + $cs = $this->getCourseSet(); + if ($cs && $cs->hasAdmissionRule(TimedAdmission::class)) { + $rule = $cs->getAdmissionRule(TimedAdmission::class); + return [ + 'start_time' => $rule->getStartTime(), + 'end_time' => $rule->getEndTime() + ]; + } + return []; + } + + /** + * Adds a user as preliminary member to this course. + * + * @param User $user The user to be added as preliminary member. + * @param string $comment An optional comment for the preliminary membership. + * + * @return AdmissionApplication The AdmissionApplication object for the preliminary membership. + * + * @throws \Studip\Exception In case the user cannot be added as preliminary member. + */ + public function addPreliminaryMember(User $user, string $comment = '') : AdmissionApplication + { + $new_admission_member = new AdmissionApplication(); + $new_admission_member->user_id = $user->id; + $new_admission_member->position = 0; + $new_admission_member->status = 'accepted'; + $new_admission_member->comment = $comment; + + $this->admission_applicants[] = $new_admission_member; + if (!$new_admission_member->store()) { + throw new \Studip\Exception( + sprintf( + _('%1$s konnte nicht als vorläufig teilnehmende Person zur Veranstaltung %2$s hinzugefügt werden.'), + $user->getFullName(), + $this->name + ), + 'add_preliminary_failed' + ); + } + if ($this->isStudygroup()) { + StudygroupModel::applicationNotice($this->id, $user->id); + } + $course_set = $this->getCourseSet(); + if ($course_set) { + AdmissionPriority::unsetPriority($course_set->getId(), $user->id, $this->id); + } + + //Create a log entry: + StudipLog::log('SEM_USER_ADD', $this->id, $user->id, 'accepted', 'Vorläufig akzeptiert'); + + return $new_admission_member; + } + + /** + * Removes a preliminary member from the course. + * + * @param User $user The member to be removed. + * + * @throws \Studip\Exception In case the user is not a preliminary member or in case they + * cannot be removed as preliminary member. + */ + public function removePreliminaryMember(User $user) : void + { + //Get the status of the user first: + $application = AdmissionApplication::findOneBySQL( + 'seminar_id = :course_id AND user_id = :user_id', + [ + 'course_id' => $this->id, + 'user_id' => $user->id + ] + ); + if (!$application) { + throw new \Studip\Exception( + sprintf( + _('%1$s ist nicht als vorläufig teilnehmende Person in der Veranstaltung %2$s eingetragen.'), + $user->getFullName(), + $this->name + ), + 'preliminary_member_not_found' + ); + } + + $deleted_from_course_set = false; + $course_set = $this->getCourseSet(); + if ($course_set) { + $deleted_from_course_set = AdmissionPriority::unsetPriority( + $course_set->getId(), + $user->id, + $this->id + ); + } + if ($application->delete() || $deleted_from_course_set) { + setTempLanguage($user->id); + $message = ''; + if ($application->status === 'accepted') { + $message = studip_interpolate( + _('Ihre vorläufige Anmeldung zur Veranstaltung %{name} wurde aufgehoben. Sie sind damit __nicht__ zugelassen worden.'), + ['name' => $this->getFullName()] + ); + } else { + $message = studip_interpolate( + _('Sie wurden von der Warteliste der Veranstaltung %{name} gestrichen. Sie sind damit __nicht__ zugelassen worden.'), + ['name' => $this->getFullName()] + ); + } + $messaging = new messaging(); + $messaging->insert_message( + $message, + $user->username, + '____%system%____', + false, + false, + '1', + false, + studip_interpolate( + _('%{course_name}: Sie wurden nicht zugelassen!'), + ['course_name' => $this->getFullName()] + ), + true + ); + restoreLanguage(); + StudipLog::log('SEM_USER_DEL', $this->id, $user->id, 'Wurde aus der Veranstaltung entfernt'); + } else { + throw new \Studip\Exception( + sprintf( + _('%1$s konnte nicht als vorläufig teilnehmende Person aus der Veranstaltung %2$s entfernt werden.'), + $user->getFullName(), + $this->name + ), + 'remove_preliminary_failed' + ); + } + } + + /** + * Adds a user to the waitlist of this course. + * + * @param User $user The user to be added onto the waitlist. + * + * @param int $position The position of the user on the waitlist. + * + * @param bool $send_mail Whether to send a mail to the user that has been added + * (true) or not (false). Defaults to true. + * + * @return AdmissionApplication The AdmissionApplication object for the added user. + * + * @throws \Studip\Exception In case the user cannot be added onto the waitlist. + */ + public function addMemberToWaitlist( + User $user, + int $position = PHP_INT_MAX, + bool $send_mail = true + ) : AdmissionApplication + { + $member_exists = AdmissionApplication::exists([$user->id, $this->id]) + || CourseMember::find([$this->id, $user->id]); + if ($member_exists) { + throw new \Studip\EnrolmentException( + sprintf( + _('%1$s ist bereits Mitglied der Veranstaltung %2$s.'), + $user->getFullName(), + $this->name + ), + \Studip\EnrolmentException::ALREADY_MEMBER + ); + } + if ($position === PHP_INT_MAX) { + //Append the user to the end of the waitlist. + //NOTE: If this method is called two times at the same time for the + //same course, there may be course members with the same position! + $position = DBManager::get()->fetchColumn( + "SELECT MAX(`position`) + FROM `admission_seminar_user` + WHERE `seminar_id` = :course_id + AND `status`='awaiting'", + ['course_id' => $this->id] + ); + if ($position === false) { + //No members on the waitlist. + $position = 0; + } + } + $new_admission_member = new AdmissionApplication(); + $new_admission_member->user_id = $user->id; + $new_admission_member->position = strval($position); + $new_admission_member->status = 'awaiting'; + $new_admission_member->seminar_id = $this->id; + if (!$new_admission_member->store()) { + throw new \Studip\EnrolmentException( + sprintf( + _('%1$s konnte nicht auf die Warteliste der Veranstaltung %2$s gesetzt werden.'), + $user->getFullName(), + $this->name + ), + \Studip\EnrolmentException::ADD_AWAITING_FAILED + ); + } + + //Reset the admission_applicants relation: + $this->resetRelation('admission_applicants'); + + //Renumber all members on the waitlist: + AdmissionApplication::renumberAdmission($this->id); + + //Create a log entry: + StudipLog::log( + 'SEM_USER_ADD', + $this->id, + $user->id, + 'awaiting', + sprintf('Auf Warteliste gesetzt, Position: %u', $position) + ); + + if ($send_mail) { + setTempLanguage($user->id); + $body = sprintf( + _('Sie wurden auf die Warteliste der Veranstaltung %s gesetzt.'), + $this->getFullName() + ); + $messaging = new messaging(); + $messaging->insert_message( + $body, + $user->username, + '____%system%____', + false, + false, + '1', + false, + _('Auf die Warteliste einer Veranstaltung eingetragen'), + true + ); + restoreLanguage(); + } + + //Everything went fine: Re-load the new admission member before returning it, + //since its position number may have changed during renumbering: + return AdmissionApplication::findOneBySQL( + '`user_id` = :user_id AND `seminar_id` = :course_id', + ['user_id' => $user->id, 'course_id' => $this->id] + ); + } + + /** + * Retrieves the course category for this course. + * + * @return SeminarCategories The category object of the course. + */ + public function getCourseCategory() : SeminarCategories + { + return SeminarCategories::GetByTypeId($this->status); + } + + /** * Retrieves all members of a status * * @param String|Array $status the status to filter with @@ -533,6 +946,579 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe return CourseMember::countByCourseAndStatus($this->id, $status); } + /** + * Adds a user to this course. + * + * @param User $user The user to be added. + * @param string $permission_level The permission level the user shall get in the course. + * @param bool $regard_contingent Whether to regard the contingent of the course (true) + * or whether to ignore it (false). Defaults to true. + * @param bool $send_mail Whether to send a mail to the new participant (true) or not (false). + * Defaults to true. + * @param bool $renumber_admission Whether to call AdmissionApplication::renumberAdmission when + * the admission of the user has been removed (true) or whether not to renumber the admission + * entries (false). Defaults to true. + * Setting this parameter to false is useful when adding several users at once and then + * manually call AdmissionApplication::renumberAdmission so that the entries are renumbered + * only once after all the users have been added. + * + * @return CourseMember The CourseMember object for the user. + * + * @throws \Studip\EnrolmentException In case the user is already in the course but cannot get a higher permission level or + * they are the only lecturer and can therefore not get a lower permission level. + */ + public function addMember( + User $user, + string $permission_level = 'autor', + bool $regard_contingent = true, + bool $send_mail = true, + bool $renumber_admission = true + ) : CourseMember + { + //TODO: Put checks for entry into Course::getEnrolmentInformation. + //Checks regarding the promotion/demotion of users in courses shall be + //transferred to a new method. + + if (!in_array($permission_level, ['user', 'autor', 'tutor', 'dozent'])) { + throw new \Studip\EnrolmentException( + _('Die Rechtestufe ist für die Eintragung in eine Veranstaltung unpassend.'), + \Studip\EnrolmentException::INVALID_PERMISSION_LEVEL + ); + } + + $db = DBManager::get(); + + //In case the course only allows users of the institute to be members, + //we must check if the user is a member of the institute: + $course_category = $this->getCourseCategory(); + if ($course_category->only_inst_user) { + //Only institute members are allowed: + $stmt = $db->prepare( + "SELECT 1 + FROM `user_inst` + JOIN `seminar_inst` USING (`institute_id`) + WHERE `user_inst`.`user_id` = :user_id + AND `seminar_inst`.`seminar_id` = :course_id" + ); + $stmt->execute([ + 'course_id' => $this->id, + 'user_id' => $user->id, + ]); + $user_in_institute = $stmt->fetchColumn(); + if (!$user_in_institute) { + throw new \Studip\EnrolmentException( + _('Die einzutragende Person ist kein Mitglied einer Einrichtung, zu der die Veranstaltung zugeordnet ist.'), + \Studip\EnrolmentException::NO_INSTITUTE_MEMBER + ); + } + } + + //Load the course member object: + $course_member = CourseMember::findOneBySQL( + '`seminar_id` = :course_id AND `user_id` = :user_id', + ['course_id' => $this->id, 'user_id' => $user->id] + ); + $new_member_position = $db->fetchColumn( + 'SELECT MAX(`position`) + 1 + FROM `seminar_user` + WHERE `status` = :status + AND `seminar_id` = :course_id', + ['status' => $permission_level, 'course_id' => $this->id] + ) ?? 0; + $number_of_lecturers = CourseMember::countByCourseAndStatus($this->id, 'dozent'); + + if (!$course_member) { + $course_member = new CourseMember(); + $course_member->seminar_id = $this->id; + $course_member->user_id = $user->id; + $course_member->status = $permission_level; + } + $course_member->position = $new_member_position; + if (in_array($permission_level, ['tutor', 'dozent'])) { + //Tutors and lecturers are always visible in the course: + $course_member->visible = 'yes'; + } else { + //All others may decide for themselves: + $course_member->visible = 'unknown'; + } + + $ranks = array_flip(['user', 'autor', 'tutor', 'dozent']); + + if ($course_member->isNew()) { + //The user shall be added to the course. Before storing, we must check + //if the contingent shall be regarded and if there is a free seat + //for the user: + + //TODO: Move the following check back to controllers. + //Background: Lecturers may enforce the entry of a student, but the latter must not + //override the checks. + if ( + $permission_level === 'autor' + && $regard_contingent + && $this->isAdmissionEnabled() + && $this->getFreeSeats() < 1 + ) { + //There is no free seat to add another member. + throw new \Studip\EnrolmentException( + sprintf( + _('Für %s ist kein Platz mehr in der Veranstaltung frei.'), + $user->getFullName() + ), + \Studip\EnrolmentException::COURSE_IS_FULL + ); + } + + $course_member->store(); + + //Delete the user from admission applications: + $application_removed = AdmissionApplication::deleteBySQL( + '`user_id` = :user_id AND `seminar_id` = :course_id', + ['user_id' => $user->id, 'course_id' => $this->id] + ); + if ($application_removed && $renumber_admission) { + //Renumber the waitlist or the other admission list: + AdmissionApplication::renumberAdmission($this->id); + } + + //Remove the user from the course set, if any: + $course_set = $this->getCourseSet(); + $removed_from_course_set = 0; + if ($course_set) { + $removed_from_course_set = AdmissionPriority::unsetPriority($course_set->getId(), $user->id, $this->id); + } + + if ($permission_level === 'dozent' && Config::get()->DEPUTIES_ENABLE) { + //Delete a possible deputy entry for the lecturer: + $deputy = Deputy::find([$this->id, $user->id]); + if ($deputy) { + $deputy->delete(); + } + + //Assign all default deputies of the lecturer to the course + //if they are not already a lecturer of the course: + $unassigned_deputies = Deputy::findBySQL( + "`range_id` = :lecturer_id + AND `user_id` NOT IN ( + SELECT `user_id` FROM `seminar_user` + WHERE `seminar_id` = :course_id + AND `status` = 'dozent' + )", + [ + 'lecturer_id' => $user->id, + 'course_id' => $this->id + ] + ); + foreach ($unassigned_deputies as $deputy) { + Deputy::addDeputy($deputy->user_id, $this->id); + } + } + + //Delete course entries in the schedule: + CalendarScheduleModel::deleteSeminarEntries($user->id, $this->id); + + //Log the event: + StudipLog::log('SEM_USER_ADD', $this->id, $user->id, $permission_level, 'Wurde in die Veranstaltung eingetragen'); + + if ($this->parent instanceof Course) { + $this->parent->addMember($user, $permission_level, false); + } + + if ($send_mail) { + setTempLanguage($user->id); + $body = ''; + $subject = ''; + if ($application_removed) { + //Enrolment after being on the wait list: + $subject = _('Zulassung zur Veranstaltung'); + $body = sprintf( + _('Sie wurden für die Veranstaltung %s zugelassen. Ihr Eintrag auf der Warteliste wurde daher entfernt.'), + $this->getFullName() + ); + } elseif ($removed_from_course_set) { + //Enrolment after being in a course set: + $subject = _('Zulassung zur Veranstaltung'); + $body = sprintf( + _('Sie wurden für die Veranstaltung %s endgültig zugelassen.'), + $this->getFullName() + ); + } else { + //Direct enrolment without waitlist or course set: + $subject = _('Eintragung in Veranstaltung'); + $body = sprintf( + _('Sie wurden in die Veranstaltung %s eingetragen.'), + $this->getFullName() + ); + } + $messaging = new messaging(); + $messaging->insert_message( + $body, + $user->username, + '____%system%____', + false, + false, + '1', + false, + $subject, + true + ); + restoreLanguage(); + } + } elseif ($ranks[$course_member->status] < $ranks[$permission_level] + && $course_member->status !== 'dozent' || $number_of_lecturers > 1) { + //The user is already a member of the course. They shall either be promoted + //or they are not a lecturer or there is more than one lecturer in the course + //(please read this multiple times in case you are unsure about these conditions). + + $course_member->status = $permission_level; + $course_member->position = $new_member_position; + + $success = !$course_member->isDirty() || $course_member->store(); + + if (!$success) { + throw new \Studip\EnrolmentException( + _('Die Person kann nicht hochgestuft werden.'), + \Studip\EnrolmentException::PROMOTION_NOT_POSSIBLE + ); + } + } elseif ($course_member->status === 'dozent' && $number_of_lecturers <= 1) { + throw new \Studip\EnrolmentException( + sprintf( + _('Die Person kann nicht herabgestuft werden, da mindestens eine lehrende Person (%1$s) in die Veranstaltung eingetragen sein muss! Tragen Sie deshalb zuerst eine weitere Person als lehrende Person (%1$s) ein und versuchen Sie es dann erneut!'), + get_title_for_status('dozent', 1, $this->status) + ), + \Studip\EnrolmentException::DEMOTION_NOT_POSSIBLE + ); + } + $this->resetRelation('members'); + + return $course_member; + } + + /** + * Removes a user from this course. + * + * @param User $user The user to be removed. + * @param bool $send_mail Whether to send a mail after the membership deletion + * (true) or not (false). Defaults to false. + * + * @return void If this method does not throw, everything went fine. + * + * @throws \Studip\MembershipException If the user cannot be removed from the course. + */ + public function deleteMember(User $user, bool $send_mail = false) : void + { + $membership = CourseMember::findOneBySQL( + 'seminar_id = :course_id AND user_id = :user_id', + ['course_id' => $this->id, 'user_id' => $user->id] + ); + if (!$membership) { + //The user is not a member of the course. + throw new \Studip\MembershipException( + sprintf( + _('%1$s ist kein Mitglied der Veranstaltung %2$s.'), + $user->getFullName(), + $this->name + ), + \Studip\MembershipException::NOT_A_MEMBER, + $user + ); + } + + if ($membership->status === 'dozent') { + //Check if there are enough lecturers left: + $lecturer_amount = CourseMember::countByCourseAndStatus($this->id, 'dozent'); + if ($lecturer_amount < 2) { + //Not enough lecturers left. + throw new \Studip\MembershipException( + sprintf( + _('In die Veranstaltung muss mindestens eine lehrende Person (%s) eingetragen sein. Um diese Person aus der Veranstaltung zu entfernen, muss zunächst eine weitere lehrende Person eingetragen werden.'), + get_title_for_status('dozent', 1, $this->status) + ), + \Studip\MembershipException::USER_IS_SOLE_LECTURER, + $user + ); + } + } + + //At this point, the user may be removed. + $success = $membership->delete(); + if (!$success) { + throw new \Studip\MembershipException( + sprintf( + _('Es trat ein Fehler auf beim Austragen von %1$s aus der Veranstaltung %2$s.'), + $user->getFullName(), + $this->getFullname() + ), + \Studip\MembershipException::REMOVAL_FAILED, + $user + ); + } + + $removed_from_parent = false; + $removed_from_children = false; + + if ($this->parent_course) { + //This course has a parent course. + //Delete the user from the parent course if they are not part of + //one of the other child courses. + $other_memberships = CourseMember::countBySql( + 'JOIN `seminare` USING (`seminar_id`) + WHERE `user_id` = :user_id + AND `parent_course` = :parent_course_id + AND `seminar_id` <> :this_course_id', + [ + 'user_id' => $user->id, + 'parent_course_id' => $this->parent_course->id, + 'this_course_id' => $this->id + ] + ); + if ($other_memberships === 0) { + //No other memberships. We can delete the user from the parent course. + $this->parent_course->deleteMember($user, false); + $removed_from_parent = true; + } + } + + if ($this->children) { + //The other way around: This course has child courses and because the user + //has been removed from this course, they shall also be removed from all + //child courses. + foreach ($this->children as $child) { + $child->deleteMember($user); + } + $removed_from_children = true; + } + + if ($send_mail) { + $messaging = new messaging(); + setTempLanguage($user->id); + $subject = sprintf(_('%s: Anmeldung aufgehoben'), $this->getFullName()); + $body = sprintf(_('Ihre Anmeldung für die Veranstaltung %s wurde aufgehoben.'), $this->getFullName()); + $messaging->insert_message( + $body, + $user->username, + '____%system%____', + false, + false, + '1', + false, + $subject, + true + ); + restoreLanguage(); + } + + if ($membership->status === 'dozent') { + //Special treatment for lecturers: + //Remove them from course dates and remove them as deputies. + + $db = DBManager::get(); + $stmt = $db->prepare( + 'DELETE FROM `termin_related_persons` + WHERE `user_id` = :user_id + AND `range_id` IN ( + SELECT `termin_id` FROM `termine` + WHERE `range_id` = :course_id + )' + ); + $stmt->execute(['course_id' => $this->id, 'user_id' => $user->id]); + + if (Deputy::isActivated()) { + //For all courses where the user is a deputy, they can be removed as deputy + //from the course, if the other lecturers are no deputies and the current user + //is not a deputy: + $all_user_deputy_duties = Deputy::findByRange_id($user->id); + foreach ($all_user_deputy_duties as $deputy_duty) { + $other_deputy_amount = Deputy::countBySql( + "JOIN `seminar_user` + ON `seminar_user`.`user_id` = `deputies`.`range_id` + WHERE `seminar_user`.`user_id` <> :deleted_user_id + AND `seminar_user`.`status` = 'dozent'", + ['deleted_user_id' => $user->id] + ); + if ($other_deputy_amount === 0 && $GLOBALS['user']->id != $deputy_duty->user_id) { + Deputy::deleteBySQL( + '`range_id` = :course_id AND `user_id` = :deputy_id', + ['course_id' => $this->id, $deputy_duty->user_id] + ); + } + } + } + } + + //Delete data field entries that are related to the user and the course: + DatafieldEntryModel::deleteBySQL( + '`range_id` = :user_id AND `sec_range_id` = :course_id', + ['user_id' => $user->id, 'course_id' => $this->id] + ); + + //Remove the user from course groups: + if ($this->statusgruppen) { + foreach ($this->statusgruppen as $group) { + $group->removeUser($user->id, true); + } + } + + StudipLog::log('SEM_USER_DEL', $this->id, $user->id, 'Wurde aus der Veranstaltung entfernt'); + + $this->resetRelation('members'); + + //At this point, removal is complete. + } + + /** + * Moves a regular course member back onto the waitlist. + * + * @param User $user The course member to be moved back to the waitlist. + * @param bool $send_mail Whether to send a mail to inform the user of them + * being moved back to the waitlist (true) or not (false). Defaults to false. + * + * @return void + * + * @throws \Studip\Exception In case the former course member cannot be moved to the waitlist. + * + * @throws \Studip\MembershipException In case the membership cannot be terminated. + */ + public function moveMemberToWaitlist(User $user, bool $send_mail = false): void + { + $this->deleteMember($user); + $this->addMemberToWaitlist($user, PHP_INT_MAX, false); + + if ($send_mail) { + setTempLanguage($user->id); + $subject = studip_interpolate( + _('%{course}: Anmeldung aufgehoben, auf Warteliste gesetzt'), + ['course' => $this->getFullName()] + ); + $message = studip_interpolate( + _('Sie wurden aus der Veranstaltung %{course} abgemeldet und auf die zugehörige Warteliste gesetzt.'), + ['course' => $this->getFullName()] + ); + messaging::sendSystemMessage($user->id, $subject, $message); + restoreLanguage(); + } + } + + /** + * Swaps the course member position with another member. This is done by specifying a course member + * and the new position where they shall be placed in the course. + * + * @param CourseMember $membership The course member to move to another position. + * + * @return int The new position of the course member. + * + * @throws \Studip\MembershipException In case when moving the member position was unsuccessful. + */ + public function swapMemberPosition(CourseMember $membership, int $new_position): int + { + //At this point, the user is not at the highest position. + //Load the member with the position $position + 1 and swap the positions. + + $next_member = CourseMember::findOneBySQL( + '`seminar_id` = :course_id AND `status` = :permission_level AND `position` = :new_position', + [ + 'course_id' => $this->id, + 'permission_level' => $membership->status, + 'new_position' => strval($new_position) + ] + ); + $success = false; + if ($next_member) { + $swapped_position = $next_member->position; + $next_member->position = $membership->position; + $membership->position = $swapped_position; + + $next_member->store(); + $success = !$membership->isDirty() || $membership->store(); + } else { + //There is a gap in the position numbers. The user can just be placed to the new position: + $membership->position = $new_position; + $success = !$membership->isDirty() || $membership->store(); + } + + if (!$success) { + //Something went wrong. + throw new \Studip\MembershipException( + sprintf( + _('%1$s konnte nicht an die Position %2$u verschoben werden.'), + $membership->user->getFullName(), + $new_position + ), + \Studip\MembershipException::MOVING_POSITION_FAILED, + $membership->user + ); + } + return (int) $membership->position; + } + + /** + * Moves a course member one position up. + * + * @param User $user The user to move up. + * + * @return int The new position of the user. + */ + public function moveMemberUp(User $user) : int + { + $membership = CourseMember::findOneBySQL( + '`seminar_id` = :course_id AND `user_id` = :user_id', + ['course_id' => $this->id, 'user_id' => $user->id] + ); + if (!$membership) { + //The user is not a member. + return -1; + } + + if ($membership->position == 0) { + //The user is already at the highest position. + return 0; + } + return $this->swapMemberPosition($membership, intval($membership->position - 1)); + } + + /** + * Moves a course member one position down. + * + * @param User $user The user to move down. + * + * @return int The new position of the user. + */ + public function moveMemberDown(User $user) : int + { + $membership = CourseMember::findOneBySQL( + '`seminar_id` = :course_id AND `user_id` = :user_id', + ['course_id' => $this->id, 'user_id' => $user->id] + ); + if (!$membership) { + //The user is not a member. + return -1; + } + + //Get the maximum number for the permission level in the course: + $stmt = DBManager::get()->prepare( + 'SELECT MAX(`position`) + FROM `seminar_user` + WHERE `seminar_id` = :course_id + AND `status` = :permission_level' + ); + $stmt->execute([ + 'course_id' => $this->id, + 'permission_level' => $membership->status, + ]); + $max_number = $stmt->fetchColumn(); + if ($max_number === false) { + //Nothing there to move. + return -1; + } + + if ($membership->position == $max_number) { + //The user is already at the lowest position. + return (int) $max_number; + } + + return $this->swapMemberPosition($membership, intval($membership->position + 1)); + } + public function getNumParticipants() { return $this->countMembersWithStatus('user autor') + $this->getNumPrelimParticipants(); @@ -564,6 +1550,188 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe } /** + * Determines the enrolment status of the user and their possibilities + * to join the course. + * + * @param string $user_id The ID of the user for which to get enrolment information. + * + * @return \Studip\EnrolmentInformation The enrolment information + * for the specified user. + */ + public function getEnrolmentInformation(string $user_id) : \Studip\EnrolmentInformation + { + //Check the course itself: + + if ($this->getSemClass()->isGroup()) { + return new \Studip\EnrolmentInformation( + _('Diese Veranstaltung ist die Hauptveranstaltung einer Veranstaltungsgruppe. Sie können sich nur in die zugehörigen Unterveranstaltungen eintragen.'), + \Studip\Information::INFO, + 'main_course', + false + ); + } + + //Check the course set and if the user is on an admission list: + + if ($course_set = $this->getCourseSet()) { + $info = new \Studip\EnrolmentInformation(''); + $info->setCodeword('course_set'); + $info->setEnrolmentAllowed(true); + $message = _('Die Anmeldung zu dieser Veranstaltung folgt bestimmten Regeln.'); + $priority = AdmissionPriority::getPrioritiesByUser($course_set->getId(), $user_id); + if (!empty($priority[$this->id])) { + if ($course_set->hasAdmissionRule('LimitedAdmission')) { + $message .= ' ' . sprintf( + _('Sie stehen auf der Anmeldeliste für die automatische Platzverteilung der Veranstaltung mit der Priorität %u.'), + $priority[$this->id] + ); + } else { + $message .= ' ' . _('Sie stehen auf der Anmeldeliste für die automatische Platzverteilung der Veranstaltung.'); + } + } + $info->setMessage($message); + return $info; + } + + if ($this->lesezugriff == '0' && Config::get()->ENABLE_FREE_ACCESS && !$GLOBALS['perm']->get_studip_perm($this->id, $user_id)) { + return new \Studip\EnrolmentInformation( + _('Für diese Veranstaltung ist keine Anmeldung erforderlich.'), + \Studip\Information::INFO, + 'free_access', + true + ); + } + + //Check the visibility of the course for the user: + if ( + !$this->visible + && !$this->isStudygroup() + && !$GLOBALS['perm']->have_perm(Config::get()->SEM_VISIBILITY_PERM, $user_id) + ) { + return new \Studip\EnrolmentInformation( + _('Sie dürfen sich in diese Veranstaltung nicht eintragen.'), + \Studip\Information::INFO, + 'invisible', + false + ); + } + + //Check the lock rule for participants: + if (LockRules::Check($this->id, 'participants')) { + return new \Studip\EnrolmentInformation( + _('Sie dürfen sich in diese Veranstaltung nicht selbst eintragen.'), + \Studip\Information::INFO, + 'locked', + false + ); + } + + //Check the permissions of the user: + + $user = User::find($user_id); + + if (!$user) { + return new \Studip\EnrolmentInformation( + _('Sie sind nicht in Stud.IP angemeldet und können sich daher nicht in die Veranstaltung eintragen.'), + \Studip\Information::WARNING, + 'nobody', + false + ); + } + if (!$GLOBALS['perm']->have_perm('user', $user_id)) { + return new \Studip\EnrolmentInformation( + _('Sie haben keine ausreichende Berechtigung, um sich in die Veranstaltung einzutragen.'), + \Studip\Information::INFO, + 'user', + false + ); + } + if ($GLOBALS['perm']->have_perm('root', $user_id)) { + return new \Studip\EnrolmentInformation( + _('Sie haben root-Rechte und dürfen damit alles in Stud.IP.'), + \Studip\Information::INFO, + 'root', + true + ); + } + if ($GLOBALS['perm']->have_studip_perm('admin', $this->id, $user_id)) { + return new \Studip\EnrolmentInformation( + _('Sie verwalten diese Veranstaltung.'), + \Studip\Information::INFO, + 'course_admin', + true + ); + } + if ($GLOBALS['perm']->have_perm('admin', $user_id)) { + return new \Studip\EnrolmentInformation( + _('Als administrierende Person dürfen Sie sich nicht in eine Veranstaltung eintragen.'), + \Studip\Information::INFO, + 'admin', + false + ); + } + + //Check the course membership: + + if ($GLOBALS['perm']->have_studip_perm('user', $this->id, $user_id)) { + return new \Studip\EnrolmentInformation( + _('Sie sind bereits in der Veranstaltung eingetragen.'), + \Studip\Information::INFO, + 'already_member', + true + ); + } + + //Check the admission status: + + $admission_status = $user->admission_applications->findBy('seminar_id', $this->id)->val('status'); + if ($admission_status === 'accepted') { + return new \Studip\EnrolmentInformation( + _('Sie wurden für diese Veranstaltung vorläufig akzeptiert.'), + \Studip\Information::INFO, + 'preliminary_accepted', + false + ); + } elseif ($admission_status === 'awaiting') { + return new \Studip\EnrolmentInformation( + _('Sie sind auf der Warteliste für diese Veranstaltung.'), + \Studip\Information::INFO, + 'on_waitlist', + false + ); + } + + //Check the user domain: + $user_domains = UserDomain::getUserDomainsForUser($user_id); + if (count($user_domains) > 0) { + //The user is in at least one domain. Check if the course is in one of them. + $course_domains = UserDomain::getUserDomainsForSeminar($this->id); + if ( + !UserDomain::checkUserVisibility($course_domains, $user_domains) + && !$this->isStudygroup() + ) { + //The user is not in the same domain as the course and the course + //is not a studygroup. + return new \Studip\EnrolmentInformation( + _('Sie sind nicht in der gleichen Domäne wie die Veranstaltung und können sich daher nicht für die Veranstaltung eintragen.'), + \Studip\Information::INFO, + 'wrong_domain', + false + ); + } + } + + //In all other cases, enrolment is allowed. + return new \Studip\EnrolmentInformation( + _('Sie können sich zur Veranstaltung anmelden.'), + \Studip\Information::INFO, + 'allowed', + true + ); + } + + + /** * Returns the semType object that is defined for the course * * @return SemType The semTypeObject for the course @@ -621,6 +1789,111 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe return trim(vsprintf($template[$format], array_map('trim', $data))); } + /** + * Retrieves all dates (regular and irregular) that take place + * in a specified semester or a semester range. + * + * @param Semester|null $start_semester The semester for which to get all dates + * or the start semester of a semester range. + * @param Semester|null $end_semester The end semester for a semester range. + * This can also be null in case only dates for one semester + * shall be retrieved. + * + * @param bool $with_cancelled_dates Whether to include cancelled dates (true) or not (false). + * Defaults to false. + * + * @return CourseDateList A collection of irregular and regular course dates. + * + * @throws \Studip\Exception In case that the end semester is before the start semester. + */ + public function getAllDatesInSemester( + ?Semester $start_semester = null, + ?Semester $end_semester = null, + bool $with_cancelled_dates = false + ) : CourseDateList { + $all_dates_of_course = !$start_semester && !$end_semester; + + if ($all_dates_of_course) { + $collection = new CourseDateList(); + foreach ($this->cycles as $regular_date) { + $collection->addRegularDate($regular_date); + } + foreach ($this->dates as $date) { + if (!$date->metadate_id) { + $collection->addSingleDate($date); + } + } + if ($with_cancelled_dates) { + foreach ($this->ex_dates as $cancelled_date) { + $collection->addCancelledDate($cancelled_date); + } + } + return $collection; + } else { + if (!$start_semester) { + return new CourseDateList(); + } + $beginning = $start_semester->beginn; + $end = $start_semester->ende; + if ($end_semester) { + if ($end_semester->ende < $start_semester->beginn) { + throw new \Studip\Exception( + _('Das Endsemester darf nicht vor dem Startsemester liegen.'), + \Studip\Exception::END_BEFORE_BEGINNING + ); + } + $end = $end_semester->ende; + } + + $collection = new CourseDateList(); + + SeminarCycleDate::findEachBySQL( + function ($date) use ($collection) { + $collection->addCycleDate($date); + }, + "`start_time` >= :beginning AND `end_time` <= :end + AND `seminar_id` = :course_id", + [ + 'course_id' => $this->id, + 'beginning' => $beginning, + 'end' => $end + ] + ); + + CourseDate::findEachBySQL( + function ($date) use ($collection) { + $collection->addSingleDate($date); + }, + "`date` >= :beginning AND `end_time` <= :end + AND `range_id` = :course_id + AND (`metadate_id` IS NULL OR `metadate_id` = '')", + [ + 'course_id' => $this->id, + 'beginning' => $beginning, + 'end' => $end + ] + ); + + if ($with_cancelled_dates) { + CourseExDate::findEachBySQL( + function ($date) use ($collection) { + $collection->addCancelledDate($date); + }, + "`date` >= :beginning AND `end_time` <= :end + AND `range_id` = :course_id + AND (`metadate_id` IS NULL OR `metadate_id` = '')", + [ + 'course_id' => $this->id, + 'beginning' => $beginning, + 'end' => $end + ] + ); + } + + return $collection; + } + } + /** * Retrieves the course dates including cancelled dates ("ex-dates"). @@ -656,6 +1929,44 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe } /** + * Retrieves the first date of the course that takes place. + * + * @return CourseDate|null Either the first date as CourseDate or null in case + * the course has no dates. + */ + public function getFirstDate() : ?CourseDate + { + return $this->dates->first(); + } + + /** + * Retrieves the next date for the course. If requested, the next cancelled + * date is retrieved if no date can be found that takes place. + * + * The date must start in the future or within the past hour to be regarded + * as next date. + * + * @param bool $include_cancelled Include cancelled dates (true) or not. + * Defaults to false. + * + * @return CourseDate|CourseExDate|null A CourseDate or CourseExDate representing + * the next date or null in case there is no next date. CourseExDate instances + * are only returned if $include_cancelled is set to true. + */ + public function getNextDate(bool $include_cancelled = false) + { + $sql = '`range_id` = :course_id AND `date` > UNIX_TIMESTAMP() - 3600 + ORDER BY `date`, `end_time`'; + + $date = CourseDate::findOneBySQL($sql, ['course_id' => $this->id]); + if (!$date && $include_cancelled) { + //Do the same with CourseExDate: + $date = CourseExDate::findOneBySQL($sql, ['course_id' => $this->id]); + } + return $date; + } + + /** * Sets this courses study areas to the given values. * * @param array $ids the new study areas @@ -1073,6 +2384,34 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe return $plugin && $this->tools->findOneby('plugin_id', $plugin->getPluginId()); } + + /** + * Returns the Plugin/Tool specified by its name in case it is + * activated in this course. + * + * @param string $name The name of the tool. + * + * @return StandardPlugin An instance for the tool. + * + * @throws \Studip\ToolException In case the tool is not activated. + */ + public function getTool(string $name) : StandardPlugin + { + if ($this->isToolActive($name)) { + $plugin = PluginEngine::getPlugin($name); + if ($plugin instanceof StandardPlugin) { + return $plugin; + } + } + throw new \Studip\ToolException( + sprintf( + _('Das Werkzeug %s ist nicht aktiviert.'), + $name + ), + \Studip\ToolException::TOOL_NOT_ACTIVATED + ); + } + /** * returns all activated plugins/modules for this course * @return StudipModule[] @@ -1177,5 +2516,4 @@ class Course extends SimpleORMap implements Range, PrivacyObject, StudipItem, Fe return $result; } - } |
