From f26137d9fe055623e869ea50d0c0d6e0fe61c37f Mon Sep 17 00:00:00 2001
From: Thomas Hackl <hackl@data-quest.de>
Date: Fri, 1 Dec 2023 12:15:33 +0000
Subject: [PATCH] Resolve "Erweiterung der Courseware-Zertifikate"

Closes #3319

Merge request studip/studip!2269
---
 .../5.5.9_extend_cw_certificates.php          |  46 +++++
 lib/classes/CoursewarePDFCertificate.php      |  23 ++-
 lib/classes/JsonApi/RouteMap.php              |   4 +
 .../Routes/Courseware/CertificateShow.php     |  67 +++++++
 lib/cronjobs/courseware.php                   | 166 ++++++++++++------
 lib/models/Courseware/Certificate.php         |  37 +++-
 lib/models/Courseware/Instance.php            |   7 +
 .../assets/stylesheets/scss/courseware.scss   |   2 +-
 .../scss/courseware/layouts/tile.scss         |   3 +-
 .../courseware/unit/CoursewareUnitItem.vue    |  35 +++-
 .../unit/CoursewareUnitItemDialogSettings.vue |  86 +++++----
 templates/courseware/mails/certificate.php    |  34 ++--
 templates/layouts/base.php                    |   1 +
 13 files changed, 391 insertions(+), 120 deletions(-)
 create mode 100644 db/migrations/5.5.9_extend_cw_certificates.php
 create mode 100644 lib/classes/JsonApi/Routes/Courseware/CertificateShow.php

diff --git a/db/migrations/5.5.9_extend_cw_certificates.php b/db/migrations/5.5.9_extend_cw_certificates.php
new file mode 100644
index 00000000000..ed05beb77da
--- /dev/null
+++ b/db/migrations/5.5.9_extend_cw_certificates.php
@@ -0,0 +1,46 @@
+<?php
+
+final class ExtendCwCertificates extends Migration
+{
+    use DatabaseMigrationTrait;
+
+    public function description()
+    {
+        return 'Provide global config entry for Courseware certificates and add a fileref_id to the ' .
+            'cw_certificates table to track which certificate was generated';
+    }
+
+    protected function up()
+    {
+        // Create global config entry for (de-)activating Courseware certificate and reminder functionality.
+        DBManager::get()->execute("INSERT IGNORE INTO `config`
+            (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`)
+            VALUES
+            (:field, :value, :type, 'global', '', UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), :description)",
+            [
+                'field' => 'COURSEWARE_CERTIFICATES_ENABLE',
+                'value' => 1,
+                'type' => 'boolean',
+                'description' => 'Schaltet Courseware-Zertifikate, -Erinnerungen und -Fortschrittsrücksetzung ein oder aus'
+            ]
+        );
+
+        if (!$this->columnExists('cw_certificates', 'fileref_id')) {
+            DBManager::get()->execute(
+                "ALTER TABLE `cw_certificates` ADD `fileref_id` CHAR(32) NULL DEFAULT NULL COLLATE latin1_bin AFTER `unit_id`"
+            );
+        }
+    }
+
+    protected function down()
+    {
+        if ($this->columnExists('cw_certificates', 'fileref_id')) {
+            DBManager::get()->execute("ALTER TABLE `cw_certificates` DROP `fileref_id`");
+        }
+
+        DBManager::get()->execute("DELETE FROM `config_values` WHERE `field` = :field",
+            ['field' => 'COURSEWARE_CERTIFICATES_ENABLE']);
+        DBManager::get()->execute("DELETE FROM `config` WHERE `field` = :field",
+            ['field' => 'COURSEWARE_CERTIFICATES_ENABLE']);
+    }
+}
diff --git a/lib/classes/CoursewarePDFCertificate.php b/lib/classes/CoursewarePDFCertificate.php
index ca6e704c27b..b70c2bea99f 100644
--- a/lib/classes/CoursewarePDFCertificate.php
+++ b/lib/classes/CoursewarePDFCertificate.php
@@ -3,19 +3,25 @@
 class CoursewarePDFCertificate extends TCPDF
 {
     protected $background;
+    protected $isCustomBackground = false;
 
     public function __construct($background = false, $orientation = 'P', $unit = 'mm', $format = 'A4', $unicode = true, $encoding = 'UTF-8')
     {
         parent::__construct($orientation, $unit, $format, $unicode, $encoding, false);
 
-        if ($background) {
+        $fileRef = null;
+        if ($background !== false) {
             $fileRef = FileRef::find($background);
-            $this->background = $fileRef->file->getPath();
-        } else {
-            $this->background = $GLOBALS['STUDIP_BASE_PATH'] . '/public/assets/images/pdf/pdf_default_background.jpg';
         }
+        $this->background = $fileRef !== null ? $fileRef->file->getPath() :
+            $GLOBALS['STUDIP_BASE_PATH'] . '/public/assets/images/pdf/pdf_default_background.jpg';
+        $this->isCustomBackground = $fileRef !== null;
 
         $this->setDefaults();
+
+        $fontname = TCPDF_FONTS::addTTFfont(
+            $GLOBALS['STUDIP_BASE_PATH'] . '/public/assets/fonts/LatoLatin/LatoLatin-Regular.ttf');
+        $this->setFont($fontname, '', 50);
     }
 
     public function Header()
@@ -23,16 +29,17 @@ class CoursewarePDFCertificate extends TCPDF
         $bMargin = $this->getBreakMargin();
         $auto_page_break = $this->AutoPageBreak;
         $this->SetAutoPageBreak(false, 0);
-        $this->Image($this->background, 0, 0, 210, 297, '', '', '', false, 300, '', false, false, 0);
-        $this->setFont('helvetica', 'B', 50);
-        $this->Cell(0, 160, 'Z E R T I F I K A T', 0, false, 'C', 0, '', 0, false, 'T', 'M');
+        list($width, $height) = getimagesize($this->background);
+        $this->Image($this->background, $this->isCustomBackground ? 10 : 0, $this->isCustomBackground ? 10 : 0,
+            min($this->getPageWidth(), $width / 10), min($this->getPageHeight(), $height / 10),
+            '', '', '', false, 300, '', false, false, 0);
         $this->SetAutoPageBreak($auto_page_break, $bMargin);
         $this->setPageMark();
     }
 
     private function setDefaults()
     {
-        $this->SetTopMargin(110);
+        $this->SetTopMargin(50);
         $this->SetLeftMargin(20);
         $this->SetRightMargin(20);
     }
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index 5542c31a4cb..e86617bcfbf 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -470,6 +470,10 @@ class RouteMap
         $group->get('/courseware-units/{id}/courseware-user-progresses', Routes\Courseware\UserProgressesOfUnitsShow::class);
         $group->patch('/courseware-user-progresses/{id}', Routes\Courseware\UserProgressesUpdate::class);
 
+        // not a JSON route
+        $group->get('/courseware-units/{id}/certificate', Routes\Courseware\CertificateShow::class);
+        $group->get('/courseware-units/{id}/certificate/{user}', Routes\Courseware\CertificateShow::class);
+
         $group->get('/courseware-blocks/{id}/comments', Routes\Courseware\BlockCommentsOfBlocksIndex::class);
         $group->post('/courseware-block-comments', Routes\Courseware\BlockCommentsCreate::class);
         $group->get('/courseware-block-comments/{id}', Routes\Courseware\BlockCommentsShow::class);
diff --git a/lib/classes/JsonApi/Routes/Courseware/CertificateShow.php b/lib/classes/JsonApi/Routes/Courseware/CertificateShow.php
new file mode 100644
index 00000000000..4a88b9b49a4
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/Courseware/CertificateShow.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace JsonApi\Routes\Courseware;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\Errors\UnsupportedRequestError;
+use JsonApi\NonJsonApiController;
+use Courseware\Unit;
+use Courseware\Certificate;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+
+/**
+ * Displays a certificate for a given courseware.
+ */
+class CertificateShow extends NonJsonApiController
+{
+    protected $allowedIncludePaths = [];
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        if (!\Config::get()->COURSEWARE_CERTIFICATES_ENABLE) {
+            throw new UnsupportedRequestError();
+        }
+
+        $unit = Unit::find($args['id']);
+        if (!$unit) {
+            throw new RecordNotFoundException('Unit could not be found');
+        }
+
+        $user = null;
+        if (isset($args['user'])) {
+            $user = \User::find($args['user']);
+            if (!$user) {
+                throw new RecordNotFoundException('User could not be found');
+            }
+        }
+
+        $config = $unit->config;
+
+        // No user given: create a preview PDF certificate
+        if (!$user) {
+            $file = Certificate::createPDF($unit, time(), null, $config['certificate']['image'] ?? '');
+
+            $response->getBody()->write(file_get_contents($file));
+
+            return $response->withHeader('Content-type', 'application/pdf');
+        // User ID given: check if a certificate exists for the given unit and output the file ID.
+        } else {
+            $certificate = Certificate::findOneBySQL(
+                "`unit_id` = :unit AND `user_id` = :user",
+                ['unit' => $unit->id, 'user' => $user->id]
+            );
+            if (!$certificate) {
+                throw new RecordNotFoundException();
+            }
+
+            $response->getBody()->write($certificate->fileref_id);
+
+            return $response->withHeader('Content-type', 'text/plain');
+        }
+    }
+}
diff --git a/lib/cronjobs/courseware.php b/lib/cronjobs/courseware.php
index d0eb491bd04..38c9807574a 100644
--- a/lib/cronjobs/courseware.php
+++ b/lib/cronjobs/courseware.php
@@ -40,13 +40,16 @@ class CoursewareCronjob extends CronJob
     {
         $verbose = $parameters['verbose'];
 
-        /*
-         * Fetch all units that have some relevant settings.
-         */
-        $todo = Courseware\Unit::findBySQL(
-            "`range_type` = 'course' AND (`config` LIKE (:cert) OR `config` LIKE (:reminder) OR `config` LIKE (:reset))",
-            ['cert' => '%"certificate":%', 'reminder' => '%"reminder":%', 'reset' => '%"reset_progress":%']
-        );
+        $todo = [];
+        if (Config::get()->COURSEWARE_CERTIFICATES_ENABLE) {
+            /*
+             * Fetch all units that have some relevant settings.
+             */
+            $todo = Courseware\Unit::findBySQL(
+                "`range_type` = 'course' AND (`config` LIKE (:cert) OR `config` LIKE (:reminder) OR `config` LIKE (:reset))",
+                ['cert' => '%"certificate":%', 'reminder' => '%"reminder":%', 'reset' => '%"reset_progress":%']
+            );
+        }
 
         if (count($todo) > 0) {
 
@@ -72,7 +75,7 @@ class CoursewareCronjob extends CronJob
 
                     // Fetch accumulated progress values for all users in this course.
                     $progresses = DBManager::get()->fetchAll(
-                        "SELECT DISTINCT p.`user_id`, SUM(p.`grade`) AS progress
+                        "SELECT DISTINCT p.`user_id`, SUM(p.`grade`) AS progress, MAX(p.`chdate`) AS pdate
                         FROM `cw_user_progresses` p
                         WHERE `block_id` IN (:blocks)
                             AND NOT EXISTS (
@@ -92,8 +95,8 @@ class CoursewareCronjob extends CronJob
                                     $progress['user_id'], $unit->range_id, $unit->id);
                             }
 
-                            if (!$this->sendCertificate($unit, $progress['user_id'], $percent,
-                                $unit->config['certificate']['image'])) {
+                            if (!$this->sendCertificate($unit, $progress['user_id'], $percent, $progress['pdate'],
+                                    $unit->config['certificate']['image'])) {
                                 printf("Could not send certificate for course %s and unit %u to user %s.\n",
                                     $unit->range_id, $unit->id, $progress['user_id']);
                             }
@@ -218,56 +221,56 @@ class CoursewareCronjob extends CronJob
         }
     }
 
-    private function sendCertificate($unit, $user_id, $progress, $image = '')
+    /**
+     * @param Courseware\Unit $unit
+     * @param string $user_id
+     * @param int $progress
+     * @param int $timestamp
+     * @param string|null $image
+     * @return bool|int|number
+     * @throws Exception
+     */
+    private function sendCertificate(Courseware\Unit $unit, string $user_id, int $progress, int $timestamp, string $image = null)
     {
         $user = User::find($user_id);
         $course = Course::find($unit->range_id);
 
-        setTempLanguage('', $user->preferred_language);
-
-        $template = $GLOBALS['template_factory']->open('courseware/mails/certificate');
-        $html = $template->render(
-            compact('user', 'course')
-        );
+        $pdf = Courseware\Certificate::createPDF($unit, $timestamp, $user, $image);
 
-        // Generate the PDF.
-        $pdf = new CoursewarePDFCertificate($image);
-        $pdf->AddPage();
-        $pdf->writeHTML($html, true, false, true, false, '');
-        $pdf_file_name = $user->nachname . '_' . $course->name . '_' . _('Zertifikat') . '.pdf';
-        $filename = $GLOBALS['TMP_PATH'] . '/' . $pdf_file_name;
-        $pdf->Output($filename, 'F');
+        $folder = $this->requireCertificateFolder($unit);
+        $data = [
+            'name'=> $user->getFullname('full') . '-' . date('ymd') . '.pdf',
+            'tmp_name'=> $pdf,
+            'type' => 'application/pdf',
+            'size' => @filesize($pdf)
+        ];
+        $file = $folder->addFile(StandardFile::create($data), $user->id);
+        @unlink($pdf);
 
-        // Send the mail with PDF attached.
-        $mail = new StudipMail();
+        setTempLanguage('', $user->preferred_language);
 
+        // Send the message containing a link to the PDF certificate.
+        $subject = _('Courseware: Zertifikat') . ' - ' . $course->getFullname() .
+            ' (' . $unit->structural_element->title . ')';
         $message = sprintf(
-            _('Anbei erhalten Sie Ihr Courseware-Zertifikat zur Veranstaltung %1$s, in der Sie einen Fortschritt ' .
-                'von %2$u %% im Lernmaterial "%s" erreicht haben.'),
-            $course->getFullname(), $progress, $unit->structural_element->title);
-        $message .= "\n\n" . _('Ãœber folgenden Link kommen Sie direkt zur Courseware') . ': ' .
-            URLHelper::getURL('dispatch.php/course/courseware/courseware/' . $unit->id, ['cid' => $course->id]);
-
-        $sent = $mail->addRecipient($user->email, $user->getFullname())
-            ->setSubject(_('Courseware: Zertifikat') . ' - ' . $course->getFullname())
-            ->setBodyText($message)
-            ->addFileAttachment($filename, $pdf_file_name)
-            ->send();
+            _('Sie haben einen Fortschritt von %1$u % % im Lernmaterial "%2$s" erreicht und können daher Ihr ' .
+                '[Zertifikat herunterladen]%3$s .'),
+            $progress, $unit->structural_element->title, $file->getDownloadURL());
+        $message .= "\n\n" . sprintf(_('Sie können das Lernmaterial [direkt hier aufrufen]%s .'),
+                URLHelper::getURL('dispatch.php/course/courseware/courseware/' . $unit->id, ['cid' => $course->id]));
+        ;
 
-        @unlink($filename);
+        messaging::sendSystemMessage($user, $subject, $message);
 
         restoreLanguage();
 
         // Add database entry for the certificate.
-        if ($sent) {
-            $cert = new Courseware\Certificate();
-            $cert->user_id = $user_id;
-            $cert->course_id = $course->id;
-            $cert->unit_id = $unit->id;
-            return $cert->store();
-        } else {
-            return false;
-        }
+        $cert = new Courseware\Certificate();
+        $cert->user_id = $user_id;
+        $cert->course_id = $course->id;
+        $cert->unit_id = $unit->id;
+        $cert->fileref_id = $file->id;
+        return $cert->store();
     }
 
     private function sendReminders($unit)
@@ -286,8 +289,10 @@ class CoursewareCronjob extends CronJob
             );
         }
 
-        $message = $unit->config['reminder']['mailText'] . "\n\n" . _('Ãœber folgenden Link kommen Sie direkt zur Courseware') . ': ' .
-            URLHelper::getURL('dispatch.php/course/courseware/courseware/' . $unit->id, ['cid' => $course->id]);
+        $message = $unit->config['reminder']['mailText'] . "\n\n" .
+            sprintf(_('Sie können das Lernmaterial [direkt hier aufrufen]%s .'),
+                URLHelper::getURL('dispatch.php/course/courseware/courseware/' . $unit->id, ['cid' => $course->id]));
+        ;
 
         $mail->setSubject(_('Courseware: Erinnerung') . ' - ' . $course->getFullname() .
                 ', ' . $unit->structural_element->title)
@@ -305,6 +310,12 @@ class CoursewareCronjob extends CronJob
             ['blocks' => $block_ids]
         );
 
+        /*
+         * If certificates are active, remove all existing entries for this unit.
+         * Note that existing PDF files will stay in their course folders.
+         */
+        Courseware\Certificate::deleteByUnit_id($unit->id);
+
         $recipients = $course->getMembersWithStatus('autor', true);
 
         $mail = new StudipMail();
@@ -318,12 +329,65 @@ class CoursewareCronjob extends CronJob
         }
 
         $message = $unit->config['reset_progress']['mailText'] . "\n\n" .
-            _('Ãœber folgenden Link kommen Sie direkt zur Courseware') . ': ' .
-            URLHelper::getURL('dispatch.php/course/courseware/courseware/' . $unit->id, ['cid' => $course->id]);
+            sprintf(_('Sie können das Lernmaterial [direkt hier aufrufen]%s .'),
+            URLHelper::getURL('dispatch.php/course/courseware/courseware/' . $unit->id, ['cid' => $course->id]));
 
         $mail->setSubject(_('Courseware: Fortschritt zurückgesetzt') . ' - ' . $course->getFullname())
             ->setBodyText($message);
 
         return $mail->send();
     }
+
+    /**
+     * Create or fetch the folder where certificates shall be put in this course.
+     * @param Courseware\Unit $unit
+     * @return FolderType
+     */
+    private function requireCertificateFolder(Courseware\Unit $unit)
+    {
+        // Try to find existing unit folder in database.
+        $unitFolder = Folder::findOneBySQL(
+            "`range_id` = :range AND `data_content` = :unit",
+            ['range' => $unit->range_id, 'unit' => json_encode(['unit_id' => $unit->id, 'download_allowed' => 1])]
+        );
+
+        // We need to create a new folder for this unit.
+        if (!$unitFolder) {
+            // Try to find existing certificate folder in database.
+            $certFolder = Folder::findOneBySQL(
+                "`range_id` = :range AND `data_content` = :data",
+                [
+                    'range' => $unit->range_id,
+                    'data' => json_encode(['purpose' => 'cw-certificates', 'download_allowed' => 1])
+                ]
+            );
+
+            // Create parent folder, collecting all certificates for all units of this course.
+            if (!$certFolder) {
+                $certFolder = FileManager::createSubFolder(
+                    Folder::findTopFolder($unit->range_id)->getTypedFolder(),
+                    User::findCurrent(),
+                    'HiddenFolder',
+                    _('Courseware-Zertifikate'),
+                    _('Erteilte Zertifikate für den Fortschritt in Courseware-Inhalten dieser Veranstaltung ')
+                );
+                $certFolder->data_content = json_encode(['purpose' => 'cw-certificates', 'download_allowed' => 1]);
+                $certFolder->store();
+            }
+
+            // General folder for certificates exists now, create the subfolder for this unit.
+            $unitFolder = FileManager::createSubFolder(
+                is_a($certFolder, FolderType::class) ? $certFolder : $certFolder->getTypedFolder(),
+                User::findCurrent(),
+                'HiddenFolder',
+                $unit->structural_element->title,
+                sprintf(_('Zertifikate für Lernmaterial %u'), $unit->id)
+            );
+            $unitFolder->data_content = json_encode(['unit_id' => $unit->id, 'download_allowed' => 1]);
+            $unitFolder->store();
+        }
+
+        return is_a($unitFolder, FolderType::class) ? $unitFolder : $unitFolder->getTypedFolder();
+    }
+
 }
diff --git a/lib/models/Courseware/Certificate.php b/lib/models/Courseware/Certificate.php
index 579c729d4be..2948a9cfc94 100644
--- a/lib/models/Courseware/Certificate.php
+++ b/lib/models/Courseware/Certificate.php
@@ -2,7 +2,7 @@
 
 namespace Courseware;
 
-use \User, \Course;
+use \User, \Course, \CoursewarePDFCertificate;
 
 /**
  * Courseware's certificates.
@@ -22,6 +22,40 @@ use \User, \Course;
  */
 class Certificate extends \SimpleORMap
 {
+    /**
+     * Generates a PDF certificate for
+     * @param Courseeware\Unit $unit
+     * @param User|null $user
+     * @param int $timestamp timestamp that shall be used as certificate date
+     * @param string|null $image optional background image fileref ID
+     * @return string Full path to the generated PDF file
+     */
+    public static function createPDF(Unit $unit, int $timestamp, ?\User $user = null, $image = null)
+    {
+        if ($user === null) {
+            $user = new User();
+            $user->vorname = 'Vorname';
+            $user->nachname = 'Nachname';
+            $user->geschlecht = 3;
+        }
+
+        $template = $GLOBALS['template_factory']->open('courseware/mails/certificate');
+        $html = $template->render(
+            compact('user', 'unit', 'timestamp')
+        );
+
+        // Generate the PDF.
+        $pdf = new CoursewarePDFCertificate($image ?? false);
+        $pdf->AddPage();
+        $pdf->writeHTML($html, true, false, true, false, '');
+        $pdf_file_name = ($user->isNew() ? 'Vorschau' : $user->nachname) . '_' . $unit->course->name . '_' .
+            _('Zertifikat') . '.pdf';
+        $filename = $GLOBALS['TMP_PATH'] . '/' . \FileManager::cleanFileName($pdf_file_name);
+        $pdf->Output($filename, 'F');
+
+        return $filename;
+    }
+
     protected static function configure($config = [])
     {
         $config['db_table'] = 'cw_certificates';
@@ -38,5 +72,4 @@ class Certificate extends \SimpleORMap
 
         parent::configure($config);
     }
-
 }
diff --git a/lib/models/Courseware/Instance.php b/lib/models/Courseware/Instance.php
index b200f33ca78..b2308b93770 100644
--- a/lib/models/Courseware/Instance.php
+++ b/lib/models/Courseware/Instance.php
@@ -273,6 +273,7 @@ class Instance
     {
         if (count($certificateSettings) > 0) {
             $this->validateCertificateSettings($certificateSettings);
+            $certificateSettings['text'] = \Studip\Markup::purifyHtml($certificateSettings['text']);
             $this->unit->config['certificate'] = $certificateSettings;
         } else {
             unset($this->unit->config['certificate']);
@@ -289,6 +290,10 @@ class Instance
     public function isValidCertificateSettings($certificateSettings): bool
     {
         return !isset($certificateSettings['threshold'])
+            || !isset($certificateSettings['title'])
+            || trim($certificateSettings['title']) !== ''
+            || !isset($certificateSettings['text'])
+            || trim($certificateSettings['text']) !== ''
             || (
                 $certificateSettings['threshold'] >= 0
                 && $certificateSettings['threshold'] <= 100
@@ -327,6 +332,7 @@ class Instance
     {
         if (count($reminderSettings) > 0) {
             $this->validateReminderSettings($reminderSettings);
+            $reminderSettings['mailText'] = \Studip\Markup::purifyHtml($reminderSettings['mailText']);
             $this->unit->config['reminder'] = $reminderSettings;
         } else {
             unset($this->unit->config['reminder']);
@@ -380,6 +386,7 @@ class Instance
     {
         if (count($resetProgressSettings) > 0) {
             $this->validateResetProgressSettings($resetProgressSettings);
+            $resetProgressSettings['mailText'] = \Studip\Markup::purifyHtml($resetProgressSettings['mailText']);
             $this->unit->config['reset_progress'] = $resetProgressSettings;
         } else {
             unset($this->unit->config['reset_progress']);
diff --git a/resources/assets/stylesheets/scss/courseware.scss b/resources/assets/stylesheets/scss/courseware.scss
index 2ec74e45855..8fdce408649 100644
--- a/resources/assets/stylesheets/scss/courseware.scss
+++ b/resources/assets/stylesheets/scss/courseware.scss
@@ -26,4 +26,4 @@
 @import './courseware/layouts/tabs.scss';
 @import './courseware/layouts/talk-bubble.scss';
 @import './courseware/layouts/tile.scss';
-@import './courseware/layouts/tree.scss';
\ No newline at end of file
+@import './courseware/layouts/tree.scss';
diff --git a/resources/assets/stylesheets/scss/courseware/layouts/tile.scss b/resources/assets/stylesheets/scss/courseware/layouts/tile.scss
index d668e696a9a..c9cfd15c245 100644
--- a/resources/assets/stylesheets/scss/courseware/layouts/tile.scss
+++ b/resources/assets/stylesheets/scss/courseware/layouts/tile.scss
@@ -129,7 +129,7 @@
 
         .description-text-wrapper {
             overflow: hidden;
-            height: 10em;
+            height: 8em;
             margin-top: 0.5em;
             display: -webkit-box;
             margin-bottom: 1em;
@@ -142,7 +142,6 @@
 
         footer {
             width: 242px;
-            text-align: right;
             color: var(--white);
             white-space: nowrap;
             overflow: hidden;
diff --git a/resources/vue/components/courseware/unit/CoursewareUnitItem.vue b/resources/vue/components/courseware/unit/CoursewareUnitItem.vue
index 80bf0a7d2bf..bf637aedc82 100644
--- a/resources/vue/components/courseware/unit/CoursewareUnitItem.vue
+++ b/resources/vue/components/courseware/unit/CoursewareUnitItem.vue
@@ -16,7 +16,7 @@
             <template #image-overlay-with-action-menu>
                 <studip-action-menu
                     class="cw-unit-action-menu"
-                    :items="menuItems"  
+                    :items="menuItems"
                     :context="title"
                     :collapseAt="0"
                     @showDelete="openDeleteDialog"
@@ -30,6 +30,9 @@
             <template #description>
                 {{ description }}
             </template>
+            <template #footer v-if="certificate">
+                <studip-icon shape="medal" :size="32" role="info_alt"></studip-icon>
+            </template>
         </courseware-tile>
         <studip-dialog
             v-if="showDeleteDialog"
@@ -69,6 +72,7 @@ import CoursewareUnitItemDialogExport from './CoursewareUnitItemDialogExport.vue
 import CoursewareUnitItemDialogSettings from './CoursewareUnitItemDialogSettings.vue';
 import CoursewareUnitItemDialogLayout from './CoursewareUnitItemDialogLayout.vue';
 import CoursewareUnitProgress from './CoursewareUnitProgress.vue';
+import axios from 'axios';
 
 import { mapActions, mapGetters } from 'vuex';
 
@@ -95,7 +99,8 @@ export default {
             showSettingsDialog: false,
             showProgressDialog: false,
             showLayoutDialog: false,
-            progresses: null
+            progresses: null,
+            certificate: null
         }
     },
     computed: {
@@ -108,6 +113,18 @@ export default {
             let menu = [];
             if (this.inCourseContext) {
                 menu.push({ id: 1, label: this.$gettext('Fortschritt'), icon: 'progress', emit: 'showProgress' });
+                if (this.certificate) {
+                    menu.push({
+                        id: 2,
+                        label: this.$gettext('Zertifikat'),
+                        icon: 'medal',
+                        url: STUDIP.URLHelper.getURL('sendfile.php', {
+                            type: 0,
+                            file_id: this.certificate,
+                            file_name: this.$gettext('Zertifikat') + '.pdf'
+                        })
+                    });
+                }
             }
             if(this.userIsTeacher && this.inCourseContext) {
                 menu.push({ id: 2, label: this.$gettext('Einstellungen'), icon: 'settings', emit: 'showSettings' });
@@ -156,6 +173,7 @@ export default {
     async mounted() {
         if (this.inCourseContext) {
             this.progresses = await this.loadUnitProgresses({unitId: this.unit.id});
+            this.checkCertificate();
         }
     },
     methods: {
@@ -165,6 +183,15 @@ export default {
             copyUnit: 'copyUnit',
             companionSuccess: 'companionSuccess'
         }),
+        async checkCertificate() {
+            if (this.getStudipConfig('COURSEWARE_CERTIFICATES_ENABLE')) {
+                const response = await axios.get(STUDIP.URLHelper.getURL('jsonapi.php/v1/courseware-units/' +
+                    this.unit.id + '/certificate/' + STUDIP.USER_ID));
+                if (response.status === 200) {
+                    this.certificate = response.data;
+                }
+            }
+        },
         executeDelete() {
             this.deleteUnit({id: this.unit.id});
         },
@@ -185,13 +212,13 @@ export default {
             this.showProgressDialog = false;
         },
         openSettingsDialog() {
-            this.showSettingsDialog = true; 
+            this.showSettingsDialog = true;
         },
         closeSettingsDialog() {
             this.showSettingsDialog = false;
         },
         openLayoutDialog() {
-            this.showLayoutDialog = true; 
+            this.showLayoutDialog = true;
         },
         closeLayoutDialog() {
             this.showLayoutDialog = false;
diff --git a/resources/vue/components/courseware/unit/CoursewareUnitItemDialogSettings.vue b/resources/vue/components/courseware/unit/CoursewareUnitItemDialogSettings.vue
index 34718fd9c13..399a87c49a1 100644
--- a/resources/vue/components/courseware/unit/CoursewareUnitItemDialogSettings.vue
+++ b/resources/vue/components/courseware/unit/CoursewareUnitItemDialogSettings.vue
@@ -30,10 +30,10 @@
                         </select>
                     </label>
                 </fieldset>
-                <fieldset>
+                <fieldset v-if="certificatesRemindersEnabled">
                     <legend>{{ $gettext('Zertifikate') }}</legend>
                     <label>
-                        <input type="checkbox" name="makecert" v-model="makeCert">
+                        <input type="checkbox" v-model="makeCert">
                         <span>
                             {{ $gettext('Zertifikat bei Erreichen einer Fortschrittsgrenze versenden') }}
                         </span>
@@ -42,35 +42,55 @@
                     </label>
                     <label v-if="makeCert">
                         <span>
-                            {{ $gettext('Erforderlicher Fortschritt (in Prozent), um ein Zertifikat zu erhalten') }}
+                            {{ $gettext('Erforderlicher Fortschritt (in Prozent)') }}
                         </span>
-                        <input type="number" min="1" max="100" name="threshold" v-model="certThreshold">
+                        <input type="number" min="1" max="100" v-model="certThreshold">
                     </label>
                     <label v-if="makeCert">
                         <span>
-                            {{ $gettext('Hintergrundbild des Zertifikats wählen') }}
+                            {{ $gettext('Bild wählen') }}
                         </span>
+                        <studip-tooltip-icon :text="$gettext('Wählen Sie hier ein Bild, das auf ' +
+                            'dem Zertifikat in der linken oberen Ecke in Originalgröße angezeigt wird. Wenn Sie das ' +
+                            'Bild als Hintergrund des Zertifikats verwenden möchten, muss es nur die passende Größe ' +
+                            'haben, um die ganze Seite auszufüllen.')"></studip-tooltip-icon>
                         <courseware-file-chooser :isImage="true" v-model="certImage" @selectFile="updateCertImage" />
                     </label>
+                    <label v-if="makeCert" class="col-3">
+                        <span>
+                            {{ $gettext('Titel') }}
+                            <input type="text" v-model="certTitle">
+                        </span>
+                    </label>
+                    <label v-if="makeCert">
+                        <span>
+                            {{ $gettext('Text') }}
+                            <studip-wysiwyg v-model="certText"></studip-wysiwyg>
+                        </span>
+                    </label>
+                    <button v-if="makeCert" type="button" class="button" @click.prevent="previewCertificate">
+                        {{ $gettext('Vorschau') }}
+                    </button>
                 </fieldset>
-                <fieldset>
+                <fieldset v-if="certificatesRemindersEnabled">
                     <legend>
                         {{ $gettext('Erinnerungen') }}
                     </legend>
                     <label>
-                        <input type="checkbox" name="sendreminders" v-model="sendReminders">
+                        <input type="checkbox" v-model="sendReminders">
                         <span>
                             {{ $gettext('Erinnerungsnachrichten an alle Teilnehmenden schicken') }}
                         </span>
                         <studip-tooltip-icon :text="$gettext('Hier können periodisch Nachrichten an alle ' +
-                        'Teilnehmenden verschickt werden, um z.B. an die Bearbeitung dieses Lernmaterials zu erinnern.')"/>
+                            'Teilnehmenden verschickt werden, um z.B. an die Bearbeitung dieses Lernmaterials ' +
+                            'zu erinnern.')"/>
                     </label>
 
                     <label v-if="sendReminders">
                         <span>
                             {{ $gettext('Zeitraum zwischen Erinnerungen') }}
                         </span>
-                        <select name="reminder_interval" v-model="reminderInterval">
+                        <select v-model="reminderInterval">
                             <option value="7">
                                 {{ $gettext('wöchentlich') }}
                             </option>
@@ -94,31 +114,28 @@
                     <label v-if="sendReminders" class="col-3">
                         <span>
                             {{ $gettext('Erstmalige Erinnerung am') }}
-                            <input type="date" name="reminder_start_date"
-                                v-model="reminderStartDate">
+                            <input type="date" v-model="reminderStartDate">
                         </span>
                     </label>
                     <label v-if="sendReminders" class="col-3">
                         <span>
                             {{ $gettext('Letztmalige Erinnerung am') }}
-                            <input type="date" name="reminder_end_date"
-                                v-model="reminderEndDate">
+                            <input type="date" v-model="reminderEndDate">
                         </span>
                     </label>
                     <label v-if="sendReminders">
                         <span>
                             {{ $gettext('Text der Erinnerungsmail') }}
-                            <textarea cols="70" rows="4" name="reminder_mail_text" data-editor="minimal"
-                                    v-model="reminderMailText"></textarea>
+                            <studip-wysiwyg v-model="reminderMailText"></studip-wysiwyg>
                         </span>
                     </label>
                 </fieldset>
-                <fieldset>
+                <fieldset v-if="certificatesRemindersEnabled">
                     <legend>
                         {{ $gettext('Fortschritt') }}
                     </legend>
                     <label>
-                        <input type="checkbox" name="resetprogress" v-model="resetProgress">
+                        <input type="checkbox" v-model="resetProgress">
                         <span>
                             {{ $gettext('Fortschritt periodisch auf 0 zurücksetzen') }}
                         </span>
@@ -129,7 +146,7 @@
                         <span>
                             {{ $gettext('Zeitraum zum Rücksetzen des Fortschritts') }}
                         </span>
-                        <select name="reset_progress_interval" v-model="resetProgressInterval">
+                        <select v-model="resetProgressInterval">
                             <option value="14">
                                 {{ $gettext('14-tägig') }}
                             </option>
@@ -150,22 +167,19 @@
                     <label v-if="resetProgress" class="col-3">
                         <span>
                             {{ $gettext('Erstmaliges Zurücksetzen am') }}
-                            <input type="date" dataformatas="" name="reset_progress_start_date"
-                                v-model="resetProgressStartDate">
+                            <input type="date" v-model="resetProgressStartDate">
                         </span>
                     </label>
                     <label v-if="resetProgress" class="col-3">
                         <span>
                             {{ $gettext('Letztmaliges Zurücksetzen am') }}
-                            <input type="date" name="reset_progress_end_date"
-                                v-model="resetProgressEndDate">
+                            <input type="date" v-model="resetProgressEndDate">
                         </span>
                     </label>
                     <label v-if="resetProgress">
                         <span>
                             {{ $gettext('Text der Rücksetzungsmail') }}
-                            <textarea cols="70" rows="4" name="reset_progress_mail_text" data-editor="minimal"
-                                    v-model="resetProgressMailText"></textarea>
+                            <studip-wysiwyg v-model="resetProgressMailText"></studip-wysiwyg>
                         </span>
                     </label>
                 </fieldset>
@@ -200,6 +214,8 @@ export default {
             makeCert: false,
             certThreshold: 0,
             certImage: '',
+            certTitle: '',
+            certText: '',
             sendReminders: false,
             reminderInterval: 7,
             reminderStartDate: '',
@@ -224,10 +240,13 @@ export default {
             } else {
                 return this.instanceById({id: 'user_' + this.context.id + '_' + this.unit.id});
             }
-            
+
         },
         inCourseContext() {
             return this.context.type === 'courses';
+        },
+        certificatesRemindersEnabled() {
+            return this.getStudipConfig('COURSEWARE_CERTIFICATES_ENABLE');
         }
     },
     methods: {
@@ -248,6 +267,8 @@ export default {
                 Object.keys(this.certSettings).length > 0;
             this.certThreshold = this.certSettings.threshold;
             this.certImage = this.certSettings.image;
+            this.certTitle = this.certSettings.title;
+            this.certText = this.certSettings.text;
             this.reminderSettings = this.currentInstance.attributes['reminder-settings'];
             this.sendReminders = typeof(this.reminderSettings) === 'object' &&
                 Object.keys(this.reminderSettings).length > 0;
@@ -275,13 +296,15 @@ export default {
             });
         },
         generateCertificateSettings() {
-            return this.makeCert ? {
+            return this.certificatesRemindersEnabled && this.makeCert ? {
                 threshold: this.certThreshold,
-                image: this.certImage
+                image: this.certImage,
+                title: this.certTitle,
+                text: this.certText
             } : {};
         },
         generateReminderSettings() {
-            return this.sendReminders ? {
+            return this.certificatesRemindersEnabled && this.sendReminders ? {
                 interval: this.reminderInterval,
                 startDate: this.reminderStartDate,
                 endDate: this.reminderEndDate,
@@ -289,7 +312,7 @@ export default {
             } : {};
         },
         generateResetProgressSettings() {
-            return this.resetProgress ? {
+            return this.certificatesRemindersEnabled && this.resetProgress ? {
                 interval: this.resetProgressInterval,
                 startDate: this.resetProgressStartDate,
                 endDate: this.resetProgressEndDate,
@@ -298,6 +321,9 @@ export default {
         },
         updateCertImage(file) {
             this.certImage = file.id;
+        },
+        previewCertificate() {
+            window.open(STUDIP.URLHelper.getURL('jsonapi.php/v1/courseware-units/' + this.unit.id + '/certificate'));
         }
     },
     async mounted() {
@@ -308,4 +334,4 @@ export default {
         this.initData();
     }
 }
-</script>
\ No newline at end of file
+</script>
diff --git a/templates/courseware/mails/certificate.php b/templates/courseware/mails/certificate.php
index 743db6f2bbf..1e948be9862 100644
--- a/templates/courseware/mails/certificate.php
+++ b/templates/courseware/mails/certificate.php
@@ -1,22 +1,12 @@
-<?php
-$p = '<p style="font-size: 20px; text-align: center;">';
-$span_bold = '<br /><br /><span style="font-size: 20px; text-align: center; font-weight: bold">';
-$span_close = '</span><br /><br />';
-switch($user->geschlecht) {
-    case 1:
-        $anrede = _('Herr');
-        break;
-    case 2:
-        $anrede = _('Frau');
-        break;
-    default:
-        $anrede= '';
-}
-echo $p;
-printf(
-    _("Hiermit wird bescheinigt, dass %1$s am %2$s erfolgreich am Seminar %3$s teilgenommen hat."),
-    $span_bold . $anrede . ' ' . $user->getFullname() . $span_close,
-    $span_bold . date('d.m.Y', time()) . $span_close,
-    $span_bold . $course->name . $span_close
-);
-echo '</p>';
+<p style="font-size: 14px; text-align: right;">
+    <?= strftime('%x', $timestamp) ?>
+</p>
+<h1 style="font-size: 20px; text-align: center">
+    <?= htmlReady($unit->config['certificate']['title']) ?>
+</h1>
+<h2 style="font-size: 14px; text-align: center">
+    <?= sprintf(_('für %s'), htmlReady($user->getFullname())) ?>
+</h2>
+<p style="font-size: 14px; text-align: center;">
+    <?= $unit->config['certificate']['text'] ?>
+</p>
diff --git a/templates/layouts/base.php b/templates/layouts/base.php
index 3378d881998..180e0207d09 100644
--- a/templates/layouts/base.php
+++ b/templates/layouts/base.php
@@ -53,6 +53,7 @@ $lang_attr = str_replace('_', '-', $_SESSION['_language']);
                 'ACTIONMENU_THRESHOLD' => Config::get()->ACTION_MENU_THRESHOLD,
                 'ENTRIES_PER_PAGE'     => Config::get()->ENTRIES_PER_PAGE,
                 'OPENGRAPH_ENABLE'     => Config::get()->OPENGRAPH_ENABLE,
+                'COURSEWARE_CERTIFICATES_ENABLE' => Config::get()->COURSEWARE_CERTIFICATES_ENABLE
             ]) ?>,
         }
     </script>
-- 
GitLab