Skip to content
Snippets Groups Projects
Select Git revision
  • 2f85abeb817dba2476294f23fcdff91140ed2e95
  • main default protected
  • step-3263
  • feature/plugins-cli
  • feature/vite
  • step-2484-peerreview
  • biest/issue-5051
  • tests/simplify-jsonapi-tests
  • fix/typo-in-1a70031
  • feature/broadcasting
  • database-seeders-and-factories
  • feature/peer-review-2
  • feature-feedback-jsonapi
  • feature/peerreview
  • feature/balloon-plus
  • feature/stock-images-unsplash
  • tic-2588
  • 5.0
  • 5.2
  • biest/unlock-blocks
  • biest-1514
21 results

_bootstrap.php

Blame
  • Forked from Stud.IP / Stud.IP
    Source project has a limited visibility.
    Code owners
    Assign users and groups as approvers for specific file changes. Learn more.
    VipsAssignment.php 37.48 KiB
    <?php
    /*
     * VipsAssignment.php - Vips test class for Stud.IP
     * Copyright (c) 2014  Elmar Ludwig
     *
     * This program is free software; you can redistribute it and/or
     * modify it under the terms of the GNU General Public License as
     * published by the Free Software Foundation; either version 2 of
     * the License, or (at your option) any later version.
     */
    
    class VipsAssignment extends SimpleORMap
    {
        /**
         * Configure the database mapping.
         */
        protected static function configure($config = [])
        {
            $config['db_table'] = 'vips_assignment';
    
            $config['serialized_fields']['options'] = 'JSONArrayObject';
    
            $config['has_many']['assignment_attempts'] = [
                'class_name'        => 'VipsAssignmentAttempt',
                'assoc_foreign_key' => 'assignment_id'
            ];
            $config['has_many']['solutions'] = [
                'class_name'        => 'VipsSolution',
                'assoc_foreign_key' => 'assignment_id'
            ];
            $config['has_many']['archived_solutions'] = [
                'class_name'        => 'VipsSolutionArchive',
                'assoc_foreign_key' => 'assignment_id'
            ];
    
            $config['belongs_to']['course'] = [
                'class_name'  => 'Course',
                'foreign_key' => 'course_id'
            ];
            $config['belongs_to']['block'] = [
                'class_name'  => 'VipsBlock',
                'foreign_key' => 'block_id'
            ];
            $config['belongs_to']['test'] = [
                'class_name'  => 'VipsTest',
                'foreign_key' => 'test_id'
            ];
    
            parent::configure($config);
        }
    
        /**
         * Initialize a new instance of this class.
         */
        public function __construct($id = null)
        {
            parent::__construct($id);
    
            if (is_null($this->options)) {
                $this->options = [];
            }
        }
    
        /**
         * Delete entry from the database.
         */
        public function delete()
        {
            $gradebook_id = $this->options['gradebook_id'];
    
            if ($gradebook_id) {
                Grading\Definition::deleteBySQL('id = ?', [$gradebook_id]);
            }
    
            VipsAssignmentAttempt::deleteBySQL('assignment_id = ?', [$this->id]);
    
            $ref_count = self::countBySql('test_id = ?', [$this->test_id]);
    
            if ($ref_count == 1) {
                $this->test->delete();
            }
    
            return parent::delete();
        }
    
        public static function importText($title, $string, $user_id, $course_id)
        {
            $duration = 7 * 24 * 60 * 60;  // one week
    
            $data_test = [
                'title'       => $title !== '' ? $title : _vips('Aufgabenblatt'),
                'description' => '',
                'user_id'     => $user_id,
                'created'     => date('Y-m-d H:i:s'),
            ];
            $data = [
                'type'        => 'practice',
                'course_id'   => $course_id ?: $user_id,
                'context'     => $course_id ? 'course' : 'user',
                'start'       => date('Y-m-d H:00:00'),
                'end'         => date('Y-m-d H:00:00', time() + $duration)
            ];
    
            // remove comments
            $string = preg_replace('/^#.*/m', '', $string);
    
            // split into exercises
            $segments = preg_split('/^Name:/m', $string);
            array_shift($segments);
    
            $test_obj = VipsTest::create($data_test);
    
            $result = new VipsAssignment();
            $result->setData($data);
            $result->test = $test_obj;
            $result->store();
    
            foreach ($segments as $segment) {
                try {
                    $new_exercise = Exercise::importText($segment);
                    $new_exercise->user_id = $user_id;
                    $new_exercise->created = date('Y-m-d H:i:s');
                    $new_exercise->store();
                    $test_obj->addExercise($new_exercise);
                } catch (Exception $e) {
                    $errors[] = $e->getMessage();
                }
            }
    
            if (isset($errors)) {
                PageLayout::postError(_vips('Während des Imports sind folgende Fehler aufgetreten:'), $errors);
            }
    
            return $result;
        }
    
        public static function importXML($string, $user_id, $course_id)
        {
            // default options
            $options = [
                'evaluation_mode' => 0,
                'released'        => 0
            ];
    
            $duration = 7 * 24 * 60 * 60;  // one week
    
            $data_test = [
                'title'       => _vips('Aufgabenblatt'),
                'description' => '',
                'user_id'     => $user_id,
                'created'     => date('Y-m-d H:i:s'),
            ];
            $data = [
                'type'        => 'practice',
                'course_id'   => $course_id ?: $user_id,
                'context'     => $course_id ? 'course' : 'user',
                'start'       => date('Y-m-d H:00:00'),
                'end'         => date('Y-m-d H:00:00', time() + $duration),
                'options'     => $options
            ];
    
            $test = new SimpleXMLElement($string, LIBXML_COMPACT | LIBXML_NOCDATA);
            $data['type'] = (string) $test['type'];
    
            if (trim($test->title) !== '') {
                $data_test['title'] = trim($test->title);
            }
            if ($test->description) {
                $data_test['description'] = Studip\Markup::purifyHtml(trim($test->description));
            }
            if ($test->notes) {
                $data['options']['notes'] = trim($test->notes);
            }
    
            if ($test->limit['access-code']) {
                $data['options']['access_code'] = (string) $test->limit['access-code'];
            }
            if ($test->limit['ip-ranges']) {
                $data['options']['ip_range'] = (string) $test->limit['ip-ranges'];
            }
            if ($test->limit['resets']) {
                $data['options']['resets'] = (int) $test->limit['resets'];
            }
            if ($test->limit['tries']) {
                $data['options']['max_tries'] = (int) $test->limit['tries'];
            }
    
            if ($test->option['scoring-mode'] == 'negative_points') {
                $data['options']['evaluation_mode'] = 1;
            } else if ($test->option['scoring-mode'] == 'all_or_nothing') {
                $data['options']['evaluation_mode'] = 2;
            }
            if ($test->option['shuffle-answers'] == 'true') {
                $data['options']['shuffle_answers'] = 1;
            }
            if ($test->option['shuffle-exercises'] == 'true') {
                $data['options']['shuffle_exercises'] = 1;
            }
    
            if ($test['start']) {
                $data['start'] = date('Y-m-d H:i:s', strtotime($test['start']));
            }
            if ($test['end']) {
                $data['end'] = date('Y-m-d H:i:s', strtotime($test['end']));
            } else if ($data['type'] === 'selftest') {
                $data['end'] = VIPS_DATE_INFINITY;
            }
            if ($test['duration']) {
                $data['options']['duration'] = (int) $test['duration'];
            }
            if ($test['block'] && $course_id) {
                $block = VipsBlock::findOneBySQL('name = ? AND course_id = ?', [$test['block'], $course_id]);
    
                if (!$block) {
                    $block = VipsBlock::create(['name' => $test['block'], 'course_id' => $course_id]);
                }
    
                $data['block_id'] = $block->id;
            }
    
            $test_obj = VipsTest::create($data_test);
    
            $result = new VipsAssignment();
            $result->setData($data);
            $result->test = $test_obj;
            $result->store();
    
            if ($test->files) {
                foreach ($test->files->file as $file) {
                    $file_id = (string) $file['id'];
                    $content = base64_decode((string) $file);
    
                    $test->registerXPathNamespace('vips', 'urn:vips:test:v1.0');
                    $file_refs = $test->xpath('vips:exercises/*/vips:file-refs/*[@ref="' . $file_id . '"]');
    
                    if ($file_refs && $content !== false) {
                        if (strlen($file_id) > 5 && strpos($file_id, 'file-') === 0) {
                            $vips_file = VipsFile::find(substr($file_id, 5));
    
                            // try to avoid reupload of identical files
                            if ($vips_file && sha1_file($vips_file->getFilePath()) === sha1($content)) {
                                $files[$file_id] = $vips_file;
                                continue;
                            }
                        }
    
                        $files[$file_id] = VipsFile::createWithString($file['name'], $content, $user_id);
                    }
                }
    
                if ($files) {
                    $mapped = preg_replace_callback('/\burn:vips:file-ref:([A-Za-z_][\w.-]*)/',
                        function($match) use ($files) {
                            $file = $files[$match[1]];
    
                            if ($file) {
                                return vips_xml_encode($file->getDownloadURL());
                            } else {
                                return $match[0];
                            }
                        }, $string);
                    $test = new SimpleXMLElement($mapped, LIBXML_COMPACT | LIBXML_NOCDATA);
                }
            }
    
            foreach ($test->exercises->exercise as $exercise) {
                try {
                    $new_exercise = Exercise::importXML($exercise);
                    $new_exercise->user_id = $user_id;
                    $new_exercise->created = date('Y-m-d H:i:s');
                    $new_exercise->store();
                    $exercise_ref = $test_obj->addExercise($new_exercise);
    
                    if ($exercise['points']) {
                        $exercise_ref->points = (float) $exercise['points'];
                        $exercise_ref->store();
                    }
    
                    if ($exercise->{'file-refs'}) {
                        foreach ($exercise->{'file-refs'}->{'file-ref'} as $file_ref) {
                            $file = $files[(string) $file_ref['ref']];
    
                            if ($file) {
                                VipsFileRef::create([
                                    'file_id'   => $file->id,
                                    'object_id' => $new_exercise->id,
                                    'type'      => 'exercise'
                                ]);
                            }
                        }
                    }
                } catch (Exception $e) {
                    $errors[] = $e->getMessage();
                }
            }
    
            if (isset($errors)) {
                PageLayout::postError(_vips('Während des Imports sind folgende Fehler aufgetreten:'), $errors);
            }
    
            return $result;
        }
    
        /**
         * Get the name of this assignment type.
         */
        public function getTypeName()
        {
            $assignment_types = self::getAssignmentTypes();
    
            return $assignment_types[$this->type]['name'];
        }
    
        /**
         * Get the icon of this assignment type.
         */
        public function getTypeIcon($role = Icon::DEFAULT_ROLE)
        {
            $assignment_types = self::getAssignmentTypes();
    
            return Icon::create($assignment_types[$this->type]['icon'], $role,
                                ['title' => $assignment_types[$this->type]['name']]);
        }
    
        /**
         * Get the list of supported assignment types.
         */
        public static function getAssignmentTypes()
        {
            return [
                'practice' => ['name' => _vips('Übung'),      'icon' => 'file'],
                'selftest' => ['name' => _vips('Selbsttest'), 'icon' => 'check-circle'],
                'exam'     => ['name' => _vips('Klausur'),    'icon' => 'doctoral_cap']
            ];
        }
    
        /**
         * Check if this assignment is locked for editing.
         */
        public function isLocked()
        {
            return $this->type === 'exam' && $this->countAssignmentAttempts() > 0;
        }
    
        /**
         * Check if this assignment is visible to this user.
         */
        public function isVisible($user_id)
        {
            return $this->block_id ? $this->block->isVisible($user_id) : true;
        }
    
        /**
         * Check if this assignment has been started.
         */
        public function isStarted()
        {
            $now = date('Y-m-d H:i:s');
    
            return $now >= $this->start;
        }
    
        /**
         * Check if this assignment is currently running.
         */
        public function isRunning()
        {
            $now = date('Y-m-d H:i:s');
    
            return $now >= $this->start && $now <= $this->end;
        }
    
        /**
         * Check if this assignment is already finished.
         */
        public function isFinished()
        {
            $now = date('Y-m-d H:i:s');
    
            return $now > $this->end;
        }
    
        /**
         * Check if this assignment has no end date.
         */
        public function isUnlimited()
        {
            return $this->type === 'selftest' && $this->end === VIPS_DATE_INFINITY;
        }
    
        /**
         * Check if this assignment may use self assessment features.
         */
        public function isSelfAssessment()
        {
            return $this->type === 'selftest' || $this->options['self_assessment'];
        }
    
        /**
         * Check if a user may reset and restart this assignment.
         */
        public function isResetAllowed()
        {
            return $this->isSelfAssessment() && $this->options['resets'] !== 0;
        }
    
        /**
         * Check if this assignment presents shuffled exercises.
         */
        public function isExerciseShuffled()
        {
            return $this->type === 'exam' && $this->options['shuffle_exercises'];
        }
    
        /**
         * Check if this assignment presents shuffled answers.
         */
        public function isShuffled()
        {
            return $this->type === 'exam' && $this->options['shuffle_answers'] !== 0;
        }
    
        /**
         * Check if this assignment is using group solutions.
         */
        public function hasGroupSolutions()
        {
            return $this->type === 'practice' && $this->options['use_groups'] !== 0;
        }
    
        /**
         * Check whether the given exercise is part of this assignment.
         *
         * @param int $exercise_id  exercise id
         */
        public function hasExercise($exercise_id)
        {
            return VipsExerciseRef::exists([$exercise_id, $this->test_id]);
        }
    
        /**
         * Return array of exercise refs in the test of this assignment.
         */
        public function getExerciseRefs($user_id)
        {
            $result = $this->test->exercise_refs->getArrayCopy();
    
            if ($this->isExerciseShuffled() && $user_id) {
                srand(crc32($this->id . ':' . $user_id));
                shuffle($result);
                srand();
            }
    
            return $result;
        }
    
        /**
         * Export this assignment to XML format. Returns the XML string.
         */
        public function exportXML()
        {
            foreach ($this->test->exercise_refs as $exercise_ref) {
                $exercise = $exercise_ref->exercise;
                $exercise->includeFilesForExport();
    
                foreach ($exercise->files as $file) {
                    $files[$file->id] = $file;
                }
            }
    
            $template = VipsPlugin::$template_factory->open('sheets/export_assignment');
            $template->assignment = $this;
            $template->files = $files;
    
            return $template->render();
        }
    
        /**
         * Check whether this assignment is editable by the given user.
         *
         * @param string $user_id   user to check (defaults to current user)
         */
        public function checkEditPermission($user_id = null)
        {
            if ($this->context === 'user') {
                return $this->course_id === ($user_id ?: $GLOBALS['user']->id);
            }
    
            return $GLOBALS['perm']->have_studip_perm('tutor', $this->course_id, $user_id);
        }
    
        /**
         * Check whether this assignment is viewable by the given user.
         *
         * @param string $user_id   user to check (defaults to current user)
         */
        public function checkViewPermission($user_id = null)
        {
            if ($this->context === 'user') {
                return $this->course_id === ($user_id ?: $GLOBALS['user']->id);
            }
    
            return $GLOBALS['perm']->have_studip_perm('autor', $this->course_id, $user_id);
        }
    
        /**
         * Check whether this assignment is accessible to a student. This is just
         * a shortcut for checking: running, active, ip address and access code.
         */
        public function checkAccess()
        {
            return $this->isRunning() && $this->active && $this->checkAccessCode() &&
                   $this->checkIPAccess($_SERVER['REMOTE_ADDR']);
        }
    
        /**
         * Check whether the access code provided for this assignment is valid.
         * If $access_code is null, the code stored in the user session is used.
         *
         * @param string $access_code access code (optional)
         */
        public function checkAccessCode($access_code = null)
        {
            if (isset($access_code)) {
                $_SESSION['vips_access_' . $this->id] = $access_code;
            } else {
                $access_code = $_SESSION['vips_access_' . $this->id];
            }
    
            return in_array($this->options['access_code'], [null, $access_code], true);
        }
    
        /**
         * Check whether the given IP address listed among the IP addresses given
         * by the lecturer for this exam (if applicable).
         *
         * @param string $ip_addr   IPv4 address (IPv6 is not yet supported)
         */
        public function checkIPAccess($ip_addr)
        {
            // not an exam: user has access.
            if ($this->type !== 'exam') {
                return true;
            }
    
            $ip_ranges = $this->options['ip_range'];
            $exam_rooms = Config::get()->VIPS_EXAM_ROOMS;
    
            // expand exam room names
            if ($exam_rooms) {
                $ip_ranges = preg_replace_callback('/#([^ ,]+)/',
                    function($match) use ($exam_rooms) {
                        return $exam_rooms[$match[1]];
                    }, $ip_ranges);
            }
    
            // Explode space separated list into an array and check the resulting single IPs
            $ip_ranges = preg_split('/[ ,]+/', $ip_ranges, -1, PREG_SPLIT_NO_EMPTY);
    
            // No IP given: user has access.
            if (count($ip_ranges) == 0) {
                return true;
            }
    
            // One or more IPs are given and user IP matches at least one: user has access.
            foreach ($ip_ranges as $ip_range) {
                if (strpos($ip_range, '/') !== false) {
                    list($ip_range, $bits) = explode('/', $ip_range);
                    $mask     = (1 << 32 - $bits) - 1;
                    $ip_start = long2ip(ip2long($ip_range) & ~$mask);
                    $ip_end   = long2ip(ip2long($ip_range) | $mask);
                } else if (strpos($ip_range, '-') !== false) {
                    list($ip_start, $ip_end) = explode('-', $ip_range);
                } else {
                    $ip_start = $ip_end = $ip_range;
                }
    
                $ip_addr_part = explode('.', $ip_addr);
                $ip_start_part = explode('.', $ip_start) + array_fill(0, 4, 0);
                $ip_end_part = explode('.', $ip_end) + array_fill(0, 4, 255);
    
                if ($ip_start_part[0] <= $ip_addr_part[0] && $ip_addr_part[0] <= $ip_end_part[0] &&
                    $ip_start_part[1] <= $ip_addr_part[1] && $ip_addr_part[1] <= $ip_end_part[1] &&
                    $ip_start_part[2] <= $ip_addr_part[2] && $ip_addr_part[2] <= $ip_end_part[2] &&
                    $ip_start_part[3] <= $ip_addr_part[3] && $ip_addr_part[3] <= $ip_end_part[3]) {
                    return true;
                }
            }
    
            return false;
        }
    
        /**
         * Get the release status of this assignment for the given user.
         * Valid values are: 0 = not released, 1 = points, 2 = comments,
         * 3 = corrections, 4 = sample solutions
         *
         * @param string $user_id   user id
         */
        public function releaseStatus($user_id)
        {
            if ($this->isStarted()) {
                $now = date('Y-m-d H:i:s');
    
                if ($this->type === 'exam') {
                    if ($this->isFinished() && $this->getAssignmentAttempt($user_id) ||
                        $this->isSelfAssessment() && $this->getUserEndTime($user_id) < $now) {
                        return $this->options['released'];
                    }
                } else {
                    if ($this->isFinished() && $this->options['released'] >= 3) {
                        return $this->options['released'];
                    }
                }
            }
    
            return 0;
        }
    
        /**
         * Count the number of assignment attempts for this assignment.
         */
        public function countAssignmentAttempts()
        {
            return VipsAssignmentAttempt::countBySql('assignment_id = ?', [$this->id]);
        }
    
        /**
         * Get the assignment attempt of the given user for this assignment.
         * Returns null if there is no assignment attempt for this user.
         *
         * @param string $user_id   user id
         */
        public function getAssignmentAttempt($user_id)
        {
            return VipsAssignmentAttempt::findOneBySQL('assignment_id = ? AND user_id = ?', [$this->id, $user_id]);
        }
    
        /**
         * Record an assignment attempt for the given user for this assignment.
         *
         * @param string $user_id   user id
         */
        public function recordAssignmentAttempt($user_id)
        {
            if (!$this->getAssignmentAttempt($user_id)) {
                if ($this->type === 'exam') {
                    $end = date('Y-m-d H:i:s', time() + $this->options['duration'] * 60);
                    $ip_address = $_SERVER['REMOTE_ADDR'];
                    $options = ['session_id' => session_id()];
                } else {
                    $end = null;
                    $ip_address = '';
                    $options = null;
                }
    
                VipsAssignmentAttempt::create([
                    'assignment_id' => $this->id,
                    'user_id'       => $user_id,
                    'start'         => date('Y-m-d H:i:s'),
                    'end'           => $end,
                    'ip_address'    => $ip_address,
                    'options'       => $options
                ]);
            }
        }
    
        /**
         * Finish an assignment attempt for the given user for this assignment.
         *
         * @param string $user_id   user id
         */
        public function finishAssignmentAttempt($user_id)
        {
            $assignment_attempt = $this->getAssignmentAttempt($user_id);
            $now = date('Y-m-d H:i:s');
    
            if ($assignment_attempt) {
                if ($assignment_attempt->end === null || $assignment_attempt->end > $now) {
                    $assignment_attempt->end = $now;
                    $assignment_attempt->store();
                }
            }
    
            return $assignment_attempt;
        }
    
        /**
         * Get the individual end time of the given user for this assignment.
         *
         * @param string $user_id   user id
         */
        public function getUserEndTime($user_id)
        {
            $assignment_attempt = $this->getAssignmentAttempt($user_id);
    
            if ($assignment_attempt) {
                $start = strtotime($assignment_attempt->start);
            } else {
                $start = time();
            }
    
            if ($assignment_attempt->end) {
                return min($assignment_attempt->end, $this->end);
            } else if ($this->type === 'exam') {
                return min(date('Y-m-d H:i:s', $start + $this->options['duration'] * 60), $this->end);
            } else {
                return $this->end;
            }
        }
    
        /**
         * Get all members that were assigned to a particular group for
         * this assignment.
         *
         * @param VipsGroup $group   The group object
         */
        public function getGroupMembers($group)
        {
            return VipsGroupMember::findBySQL(
                'group_id = ? AND start <= ? AND (end > ? OR end IS NULL)',
                [$group->id, $this->end, $this->end]
            );
        }
    
        /**
         * Get the group the user was assigned to for this assignment.
         * Returns null if there is no group assignment for this user.
         *
         * @param string $user_id   user id
         */
        public function getUserGroup($user_id)
        {
            if (!$this->hasGroupSolutions()) {
                return null;
            }
    
            return VipsGroup::findOneBySQL(
                'JOIN vips_group_member ON group_id = id
                 WHERE course_id = ?
                   AND user_id   = ?
                   AND start    <= ?
                   AND (end      > ? OR end IS NULL)',
                [$this->course_id, $user_id, $this->end, $this->end]
            );
        }
    
        /**
         * Store a solution related to this assignment into the database.
         *
         * @param VipsSolution $solution The solution object
         */
        public function storeSolution($solution)
        {
            $exercise = $solution->exercise;
            $user_id  = $solution->user_id;
    
            $solution->assignment = $this;
            $solution->time = date('Y-m-d H:i:s');
    
            // store some client info for exams
            if ($this->type === 'exam') {
                $solution->ip_address = $_SERVER['REMOTE_ADDR'];
                $solution->options['session_id'] = session_id();
            }
    
            // in selftests, autocorrect solution
            if ($this->isSelfAssessment()) {
                $this->correctSolution($solution);
            }
    
            // move old solutions into vips_solution_archive
            VipsSolution::archiveBySQL(
                'exercise_id = ? AND assignment_id = ? AND user_id = ?',
                [$exercise->id, $this->id, $user_id]
            );
    
            // insert new solution into vips_solution
            return $solution->store();
        }
    
        /**
         * Correct a solution and store the points for the solution in the object.
         *
         * @param VipsSolution $solution The solution object
         * @param bool $corrected        mark solution as corrected
         */
        public function correctSolution($solution, $corrected = false)
        {
            $exercise = $solution->exercise;
            $exercise_ref = $this->test->getExerciseRef($exercise->id);
            $max_points = (float) $exercise_ref->points;
    
            // always set corrected to true for selftest exercises
            $selftest   = $this->type === 'selftest';
            $evaluation = $exercise->evaluate($solution);
            $feedback   = $exercise->options['feedback'];
            $eval_safe  = $selftest ? $evaluation['safe'] !== null : $evaluation['safe'];
    
            $reached_points = round_to_half_point($evaluation['percent'] * $max_points);
            $corrected      = (int) ($corrected || $eval_safe);
    
            // insert solution points
            $solution->corrected = $corrected;
            $solution->points = $reached_points;
            $solution->correction_time = date('Y-m-d H:i:s');
    
            if ($selftest && $evaluation['percent'] != 1 && $feedback != '') {
                $solution->corrector_comment = $feedback;
            }
        }
    
        /**
         * Restores an archived solution as the current solution.
         *
         * @param VipsSolutionArchive $solution The solution object
         */
        public function restoreSolution($solution)
        {
            if ($solution->isArchived() && $solution->assignment_id == $this->id) {
                $new_solution = VipsSolution::build($solution);
                $new_solution->id = 0;
                $new_solution->files = $solution->files;
                $this->storeSolution($new_solution);
            }
        }
    
        /**
         * Fetch archived solutions related to this assignment from the database.
         * Returns empty list if there are no archived solutions for this exercise.
         *
         * @param string $group_id  group id
         * @param int $exercise_id  exercise id
         */
        public function getArchivedGroupSolutions($group_id, $exercise_id)
        {
            return VipsSolutionArchive::findBySQL(
                'JOIN vips_group_member USING(user_id)
                 WHERE exercise_id   = ?
                   AND assignment_id = ?
                   AND group_id      = ?
                   AND start        <= ?
                   AND (end          > ? OR end IS NULL)
                 ORDER BY time DESC',
                [$exercise_id, $this->id, $group_id, $this->end, $this->end]
            );
        }
    
        /**
         * Fetch archived solutions related to this assignment from the database.
         * NOTE: This method will NOT check the group solutions, if applicable.
         * Returns empty list if there are no archived solutions for this exercise.
         *
         * @param string $user_id   user id
         * @param int $exercise_id  exercise id
         */
        public function getArchivedUserSolutions($user_id, $exercise_id)
        {
            return VipsSolutionArchive::findBySQL(
                'exercise_id = ? AND assignment_id = ? AND user_id = ? ORDER BY time DESC',
                [$exercise_id, $this->id, $user_id]
            );
        }
    
        /**
         * Fetch archived solutions related to this assignment from the database.
         * Returns empty list if there are no archived solutions for this exercise.
         *
         * @param string $user_id   user id
         * @param int $exercise_id  exercise id
         */
        public function getArchivedSolutions($user_id, $exercise_id)
        {
            $group = $this->getUserGroup($user_id);
    
            if ($group) {
                return $this->getArchivedGroupSolutions($group->id, $exercise_id);
            }
    
            return $this->getArchivedUserSolutions($user_id, $exercise_id);
        }
    
        /**
         * Fetch a solution related to this assignment from the database.
         * Returns null if there is no solution for this exercise yet.
         *
         * @param string $group_id  group id
         * @param int $exercise_id  exercise id
         */
        public function getGroupSolution($group_id, $exercise_id)
        {
            return VipsSolution::findOneBySQL(
                'JOIN vips_group_member USING(user_id)
                 WHERE exercise_id   = ?
                   AND assignment_id = ?
                   AND group_id      = ?
                   AND start        <= ?
                   AND (end          > ? OR end IS NULL)
                 ORDER BY time DESC',
                [$exercise_id, $this->id, $group_id, $this->end, $this->end]
            );
        }
    
        /**
         * Fetch a solution related to this assignment from the database.
         * NOTE: This method will NOT check the group solution, if applicable.
         * Returns null if there is no solution for this exercise yet.
         *
         * @param string $user_id   user id
         * @param int $exercise_id  exercise id
         */
        public function getUserSolution($user_id, $exercise_id)
        {
            return VipsSolution::findOneBySQL(
                'exercise_id = ? AND assignment_id = ? AND user_id = ?',
                [$exercise_id, $this->id, $user_id]
            );
        }
    
        /**
         * Fetch a solution related to this assignment from the database.
         * Returns null if there is no solution for this exercise yet.
         *
         * @param string $user_id   user id
         * @param int $exercise_id  exercise id
         */
        public function getSolution($user_id, $exercise_id)
        {
            $group = $this->getUserGroup($user_id);
    
            if ($group) {
                return $this->getGroupSolution($group->id, $exercise_id);
            }
    
            return $this->getUserSolution($user_id, $exercise_id);
        }
    
        /**
         * Delete all solutions of the given user for a single exercise of
         * this test from the DB.
         *
         * @param string $user_id   user id
         * @param int $exercise_id  exercise id
         */
        public function deleteSolution($user_id, $exercise_id)
        {
            $sql = 'exercise_id = ? AND assignment_id = ? AND user_id = ?';
    
            if ($this->isSelfAssessment()) {
                // delete in vips_solution and vips_solution_archive
                VipsSolution::deleteBySQL($sql, [$exercise_id, $this->id, $user_id]);
                VipsSolutionArchive::deleteBySQL($sql, [$exercise_id, $this->id, $user_id]);
            } else {
                // move solutions into vips_solution_archive
                VipsSolution::archiveBySQL($sql, [$exercise_id, $this->id, $user_id]);
            }
    
            // update gradebook if necessary
            $this->updateGradebookEntries($user_id);
        }
    
        /**
         * Delete all solutions of the given user for this test from the DB.
         *
         * @param string $user_id   user id
         */
        public function deleteSolutions($user_id)
        {
            $sql = 'assignment_id = ? AND user_id = ?';
    
            if ($this->isSelfAssessment()) {
                // delete in vips_solution and vips_solution_archive
                VipsSolution::deleteBySQL($sql, [$this->id, $user_id]);
                VipsSolutionArchive::deleteBySQL($sql, [$this->id, $user_id]);
            } else {
                // move solutions into vips_solution_archive
                VipsSolution::archiveBySQL($sql, [$this->id, $user_id]);
            }
    
            // delete start times
            VipsAssignmentAttempt::deleteBySQL($sql, [$this->id, $user_id]);
    
            // update gradebook if necessary
            $this->updateGradebookEntries($user_id);
        }
    
        /**
         * Delete all solutions of all users for this test from the DB.
         */
        public function deleteAllSolutions()
        {
            $sql = 'assignment_id = ?';
    
            if ($this->isSelfAssessment()) {
                // delete in vips_solution and vips_solution_archive
                VipsSolution::deleteBySQL($sql, [$this->id]);
                VipsSolutionArchive::deleteBySQL($sql, [$this->id]);
            } else {
                // move solutions into vips_solution_archive
                VipsSolution::archiveBySQL($sql, [$this->id]);
            }
    
            // delete start times
            VipsAssignmentAttempt::deleteBySQL($sql, [$this->id]);
    
            // update gradebook if necessary
            $this->updateGradebookEntries();
        }
    
        /**
         * Count the number of solutions of the given user for this test.
         *
         * @param string $user_id   user id
         */
        public function countSolutions($user_id)
        {
            $solutions = 0;
    
            foreach ($this->test->exercise_refs as $exercise_ref) {
                if ($this->getSolution($user_id, $exercise_ref->exercise_id)) {
                    ++$solutions;
                }
            }
    
            return $solutions;
        }
    
        /**
         * Return the points a user has reached in all exercises in this assignment.
         *
         * @param string $user_id   user id
         */
        public function getUserPoints($user_id)
        {
            $group = $this->getUserGroup($user_id);
    
            if ($group) {
                $user_ids = array_column_object($this->getGroupMembers($group), 'user_id');
            } else {
                $user_ids = [$user_id];
            }
    
            $solutions = $this->solutions->findBy('user_id', $user_ids)->orderBy('time');
            $points = [];
    
            foreach ($solutions as $solution) {
                $points[$solution->exercise_id] = (float) $solution->points;
            }
    
            return max(array_sum($points), 0);
        }
    
        /**
         * Copy this assignment into the given course. Returns the new assignment.
         *
         * @param string $course_id course id
         */
        public function copyIntoCourse($course_id, $context = 'course')
        {
            // determine title of new assignment
            if ($this->course_id === $course_id) {
                $title = sprintf(_vips('Kopie von %s'), $this->test->title);
            } else {
                $title = $this->test->title;
            }
    
            // reset released option for new assignment
            $options = $this->options;
            unset($options['released']);
            unset($options['stopdate']);
            unset($options['gradebook_id']);
    
            $new_test = VipsTest::create([
                'title'       => $title,
                'description' => $this->test->description,
                'user_id'     => $GLOBALS['user']->id,
                'created'     => date('Y-m-d H:i:s')
            ]);
    
            $new_assignment = VipsAssignment::create([
                'test_id'   => $new_test->id,
                'course_id' => $course_id,
                'context'   => $context,
                'type'      => $this->type,
                'start'     => $this->start,
                'end'       => $this->end,
                'options'   => $options
            ]);
    
            foreach ($this->test->exercise_refs as $exercise_ref) {
                $exercise_ref->copyIntoTest($new_test->id, $exercise_ref->position);
            }
    
            return $new_assignment;
        }
    
        /**
         * Move this assignment into the given course.
         *
         * @param string $course_id course id
         */
        public function moveIntoCourse($course_id, $context = 'course')
        {
            if ($this->course_id !== $course_id) {
                $this->course_id = $course_id;
                $this->context = $context;
                $this->block_id = null;
                $this->removeFromGradebook();
                $this->store();
            }
        }
    
        /**
         * Insert this assignment into the gradebook of its course.
         *
         * @param string $title     gradebook title
         * @param string $weight    gradebook weight
         */
        public function insertIntoGradebook($title, $weight = 1)
        {
            $gradebook_id = $this->options['gradebook_id'];
    
            if (!$gradebook_id) {
                $definition = Grading\Definition::create([
                    'course_id' => $this->course_id,
                    'item'      => $this->id,
                    'name'      => $title,
                    'tool'      => VipsPlugin::$instance->getPluginName(),
                    'category'  => $this->getTypeName(),
                    'position'  => strtotime($this->start),
                    'weight'    => $weight
                ]);
    
                $this->options['gradebook_id'] = $definition->id;
                $this->store();
            }
        }
    
        /**
         * Remove this assignment from the gradebook of its course.
         */
        public function removeFromGradebook()
        {
            $gradebook_id = $this->options['gradebook_id'];
    
            if ($gradebook_id) {
                Grading\Definition::find($gradebook_id)->delete();
    
                unset($this->options['gradebook_id']);
                $this->store();
            }
        }
    
        /**
         * Update some or all gradebook entries of this assignment. If the
         * user_id is specified, only update entries related to this user.
         *
         * @param string $user_id   user id
         */
        public function updateGradebookEntries($user_id = null)
        {
            $gradebook_id = $this->options['gradebook_id'];
    
            if ($gradebook_id) {
                $max_points = $this->test->getTotalPoints() ?: 1;
    
                if ($user_id) {
                    $group = $this->getUserGroup($user_id);
                }
    
                if ($group) {
                    $members = $this->getGroupMembers($group);
                } else if ($user_id) {
                    $members = [(object) compact('user_id')];
                } else {
                    $members = $this->course->members->findBy('status', 'autor');
                }
    
                foreach ($members as $member) {
                    $reached_points = $this->getUserPoints($member->user_id);
                    $entry = new Grading\Instance([$gradebook_id, $member->user_id]);
    
                    if ($reached_points) {
                        $entry->rawgrade = $reached_points / $max_points;
                        $entry->store();
                    } else {
                        $entry->delete();
                    }
                }
            }
        }
    }