Compare commits

..
Author SHA1 Message Date
Johann Hoffmann aeb6b0c907 fix AnrechnungJob.php person_id ambiguity; also checked some possible prestudent/student ambiguity in AnrechnungLib.php even if that would be rare; fixed a faulty transaction abort from return error to return false; 2026-10-07 09:58:26 +02:00
Johann Hoffmann a87266c325 icon fixes in request anrechnung; block request dispaly reason when lv is not in studienplan of logged in users prestudent history; added mehrsprachigkeit to anrechnung begruendung options in db; more phrasen; 2026-10-05 17:04:22 +02:00
Johann Hoffmann 663430b462 fix anrechnung email job select to always send to correct stgl; new helper methods isBerechtigtForAnrechnung & isBerechtigtForPrestudent for approval/reject & request mechanism; more phrasen; move berechtigungsprüfungen from view redundantly into controller to MAYBE have it replaced by a vuejs page in the future; 2026-10-05 15:19:29 +02:00
Johann Hoffmann 352d7b10d7 try to force using phrasen system throughout the whole Anrechnungen - WIP; fixed fontawesome bug by upgrading to v6; fixed tabulator persistence across multiple pages definitions, Filter.js and TableWidget.js by switching from columns: true to columns: ["width", "visible"], since they otherwhise might not get persisted; WIP working out the implications/solutions for multiaccount students applying for anrechnungen to an lv from a different study program of theirs; 2026-10-02 17:34:20 +02:00
Andreas Österreicher 36064afbba Fixed Testing Projektarbeit Seeds 2026-10-01 15:33:05 +02:00
Harald Bamberger f3d031ab16 cismenu: add cis-main-menu class to main nav, add li elements to ul, style cis-main-menu ul and li to prevent interferec with cms content styles, add draggable false to cis menu entries to improve menu scrolling especially on mobile devices 2026-10-01 11:48:51 +02:00
Harald Bamberger 3d8c4fa4d0 Merge branch 'feature-77757/WidgetsBerechtigung' 2026-09-30 15:48:00 +02:00
Harald Bamberger 272014bd78 remove odd phrases 2026-09-30 15:38:43 +02:00
Harald Bamberger 76a8e00edf Merge branch 'master' into feature-77757/WidgetsBerechtigung 2026-09-30 15:23:08 +02:00
Johann Hoffmann d40040efd8 added new fkey to tbl_widget for system.tbl_berechtigung; check for permission status during load & in widget picker; if widget was already picked/configured but not permitted anymore -> show a generalized "missing permission" screen via fallback component during widget init; 2026-07-21 18:14:26 +02:00
129 changed files with 1334 additions and 16276 deletions
-21
View File
@@ -1,21 +0,0 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
// Preferred organisational unit for a new content.
// If the user is entitled for this unit, it wins.
// If not, the first entitled unit wins.
$config['default_oe_kurzbz'] = 'etw';
// Lifetime of an edit lock in hours, counted from tbl_contentlog.start. Must be > 0.
$config['lock_ttl_hours'] = 24;
// Switches the click statistics off completely. With false the admin tab stays hidden and
// the endpoint refuses every request, whatever the rights of the caller are.
$config['clickstats_enabled'] = true;
// Default period of the click statistics in months, when the caller sends none.
$config['clickstats_months'] = 12;
// Length of the ranking in the click statistics tab.
$config['clickstats_limit'] = 100;
-4
View File
@@ -75,10 +75,6 @@ $route['Abgabetool/Student'] = 'Cis/Abgabetool/Student';
$route['Abgabetool/Student/(:any)'] = 'Cis/Abgabetool/Student/$1';
$route['Abgabetool/Deadlines'] = 'Cis/Abgabetool/Deadlines';
// CMS Admin App Routes
$route['cms'] = 'cms/index';
$route['cms/.*'] = 'cms/index';
// Studierendenverwaltung App Routes
$route['studvw'] = 'studentenverwaltung/index';
$route['studvw/.*'] = 'studentenverwaltung/index';
+7 -10
View File
@@ -44,22 +44,19 @@ class Cms extends Auth_Controller
*
* @return void
*/
public function content($content_id = null, $version = null, $sprache = null, $sichtbar = true)
public function content($content_id, $version = null, $sprache = null, $sichtbar = true)
{
if (!is_numeric($content_id))
show_404();
// return early if the content_id for the content is missing
if (!isset($content_id))
$this->terminateWithError("content_id is missing");
$content = $this->ContentModel->load($content_id);
if (isError($content))
{
show_error('The content could not be loaded');
}
$this->terminateWithError(getError($content));
$content = getData($content);
if (NULL === $content) {
show_404();
}
if (NULL === $content)
$this->terminateWithError("Content not found");
$content = current($content);
-23
View File
@@ -1,23 +0,0 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class Cms extends Auth_Controller
{
public function __construct()
{
parent::__construct(['index' => 'basis/cms:r']);
$this->load->library('PermissionLib');
$this->config->load('cms');
$this->loadPhrases(['global', 'ui', 'cms']);
}
public function index()
{
// decide whether the click statistics tab is shown at all.
$this->load->view('Cms.php', [
'clickstats' => $this->config->item('clickstats_enabled')
&& $this->permissionlib->isBerechtigt('admin')
]);
}
}
+6 -340
View File
@@ -25,10 +25,6 @@ if (! defined('BASEPATH')) exit('No direct script access allowed');
*/
class Cms extends FHCAPI_Controller
{
// Upper bound for the tiefe of oe-personen and dms-liste. A hierarchy this deep does
// not occur, and the cap keeps one marker from walking a whole tree.
const MAX_TIEFE = 10;
/**
* Object initialization
@@ -43,11 +39,6 @@ class Cms extends FHCAPI_Controller
'news' => self::PERM_LOGGED,
'getNewsRowCount' => self::PERM_LOGGED,
'getNews' => self::PERM_LOGGED,
'getOePersonen' => self::PERM_LOGGED,
'getDmsKategorie' => self::PERM_LOGGED,
'getContentChilds' => self::PERM_LOGGED,
'getPerson' => self::PERM_LOGGED,
'getDmsDokumente' => self::PERM_LOGGED,
]);
@@ -64,7 +55,11 @@ class Cms extends FHCAPI_Controller
]);
}
//------------------------------------------------------------------------------------------------------------------
// Private methods
//------------------------------------------------------------------------------------------------------------------
// Public methods
@@ -85,10 +80,7 @@ class Cms extends FHCAPI_Controller
$sprache = $this->input->get("sprache",TRUE);
$sichtbar = $this->input->get("sichtbar",TRUE);
// cms admin preview renders a content without counting a view
$preview = $this->input->get("preview",TRUE);
$content = $this->cmslib->getContent($content_id, $version, $sprache, $sichtbar, !$preview);
$content = $this->cmslib->getContent($content_id, $version, $sprache, $sichtbar);
$content = $this->getDataOrTerminateWithError($content);
$this->terminateWithSuccess($content);
@@ -211,331 +203,5 @@ class Cms extends FHCAPI_Controller
}
/**
* Lists the people assigned to an organisation unit.
* Used by the CMS content component "oe-personen".
*/
public function getOePersonen()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('oe_kurzbz', 'Organisationseinheit', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel');
$this->load->model('organisation/Organisationseinheit_model', 'OrganisationseinheitModel');
$oe_kurzbz = $this->input->get('oe_kurzbz', TRUE);
$mitFoto = $this->input->get('foto', TRUE) ? true : false;
// tiefe 0 asks about the one unit. Deeper takes the sub units with it.
$oeResult = $this->OrganisationseinheitModel->getMitUntereinheiten(
$oe_kurzbz, $this->_tiefe()
);
$oeRows = $this->getDataOrTerminateWithError($oeResult) ?: array();
$uids = array();
$funktionen = array();
foreach ($oeRows as $oe)
{
// 'oezuordnung' is the function that assigns an employee to an organisation
// unit. The model already filters datum_von and datum_bis.
$rows = $this->BenutzerfunktionModel->getBenutzerFunktionenDetailed(
'oezuordnung', $oe->oe_kurzbz
);
$rows = $this->getDataOrTerminateWithError($rows) ?: array();
foreach ($rows as $row)
{
// The model returns the aktiv flag but does not filter on it.
if (!$row->aktiv)
continue;
// One person can hold the assignment in several units of the walk. The
// first one wins, so the list keeps one block per person.
if (isset($funktionen[$row->uid]))
continue;
$uids[] = $row->uid;
// tbl_benutzerfunktion.bezeichnung is the free label of one assignment,
// for example "Bibliothekarin". tbl_funktion.beschreibung is the generic
// name.
$funktionen[$row->uid] = $row->bezeichnung ?: $row->beschreibung;
}
}
$this->terminateWithSuccess($this->_cmsPersonen($uids, $funktionen, $mitFoto));
}
/**
* Contact data of one person.
* Used by the CMS content component "person-block".
*/
public function getPerson()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('uid', 'UID', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$uid = $this->input->get('uid', TRUE);
$mitFoto = $this->input->get('foto', TRUE) ? true : false;
// The function label comes from the marker, not from the database. The component
// passes it through as a property.
$personen = $this->_cmsPersonen(array($uid), array(), $mitFoto);
$this->terminateWithSuccess($personen ? current($personen) : null);
}
/**
* Lists the published documents of one DMS category.
* Used by the CMS content component "dms-liste".
*/
public function getDmsKategorie()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$kategorie_kurzbz = $this->input->get('kategorie_kurzbz', TRUE);
$erlaubt = $this->_dmsKategorieErlaubt($kategorie_kurzbz);
if ($erlaubt === null)
$this->terminateWithError('DMS category not found', self::ERROR_TYPE_404);
// a missing entitlement answers with success and an empty list, not with
// an error. The component sits inside a public content page. An error would raise a
// toast for every reader who is simply not in the group.
$dokumente = array();
if ($erlaubt)
{
// tiefe 0 asks about the one category. Deeper takes the sub categories with it.
$katResult = $this->DmsModel->getMitUnterkategorien(
$kategorie_kurzbz, $this->_tiefe()
);
$kategorien = $this->getDataOrTerminateWithError($katResult) ?: array();
foreach ($kategorien as $kat)
{
// Every category carries its own group rule, so a sub category can be
// closed while the parent is open. Ask for each one instead of letting the
// entitlement of the root stand for the whole walk. The root is already
// answered above.
if ($kat->kategorie_kurzbz !== $kategorie_kurzbz
&& $this->_dmsKategorieErlaubt($kat->kategorie_kurzbz) !== true)
continue;
$rows = $this->DmsModel->getKategorieDokumente($kat->kategorie_kurzbz);
$rows = $this->getDataOrTerminateWithError($rows) ?: array();
foreach ($rows as $row)
$dokumente[] = $row;
}
}
$this->terminateWithSuccess(array(
'zugriff' => $erlaubt,
'dokumente' => $dokumente
));
}
/**
* Lists named documents, in the order the caller asked for them.
* Used by the CMS content component "dms-dokumente".
*/
public function getDmsDokumente()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('dms_ids', 'DMS-IDs', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('content/Dms_model', 'DmsModel');
$ids = array();
foreach (explode(',', $this->input->get('dms_ids', TRUE)) as $teil)
{
$teil = trim($teil);
if (is_numeric($teil))
$ids[] = (int) $teil;
}
// An unbounded list would let one marker fetch the whole DMS.
$ids = array_slice(array_unique($ids), 0, 50);
$dokumente = $this->DmsModel->getDokumenteByIds($ids);
$dokumente = $this->getDataOrTerminateWithError($dokumente) ?: array();
// A hand picked list holds few categories, so deciding per category is cheap and
// reuses the rule of getDmsKategorie().
$erlaubte = array();
$nachId = array();
foreach ($dokumente as $dokument)
{
if (!array_key_exists($dokument->kategorie_kurzbz, $erlaubte))
$erlaubte[$dokument->kategorie_kurzbz] = $this->_dmsKategorieErlaubt($dokument->kategorie_kurzbz);
if ($erlaubte[$dokument->kategorie_kurzbz] === true)
$nachId[$dokument->dms_id] = $dokument;
}
// The editor chose the order. Keep it.
$sortiert = array();
foreach ($ids as $id)
{
if (isset($nachId[$id]))
$sortiert[] = $nachId[$id];
}
$this->terminateWithSuccess($sortiert);
}
/**
* Lists the child pages of a content, as a reader may see them.
* Used by the CMS content component "contentchild-menu".
*/
public function getContentChilds()
{
// form validation
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural_no_zero');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('content/Contentchild_model', 'ContentchildModel');
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (!$sprache)
$sprache = getUserLanguage();
$childs = $this->ContentchildModel->getChildsForReader($content_id, $sprache, getAuthUID());
$this->terminateWithSuccess($this->getDataOrTerminateWithError($childs) ?: array());
}
//------------------------------------------------------------------------------------------------------------------
// Private methods
/**
* Builds the shape both person content components render.
*
* The assembly follows Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the
* same in the news extras and on a content page.
*
* @param array $uids in the order the result should keep
* @param array $funktionen uid => function label, may be empty
* @param boolean $mitFoto
* @return array
*/
private function _cmsPersonen($uids, $funktionen, $mitFoto)
{
if (empty($uids))
return array();
$this->load->model('person/Person_model', 'PersonModel');
$kontakt = $this->PersonModel->getCmsKontakt($uids, $mitFoto);
$kontakt = $this->getDataOrTerminateWithError($kontakt) ?: array();
$nachUid = array();
foreach ($kontakt as $row)
$nachUid[$row->uid] = $row;
$personen = array();
// Loops over $uids, not over the result, so the caller keeps its order.
foreach ($uids as $uid)
{
if (!isset($nachUid[$uid]))
continue;
$row = $nachUid[$uid];
$name = trim(implode(' ', array_filter(array(
$row->titelpre, $row->vorname, $row->nachname, $row->titelpost
))));
$telefon = '';
if ($row->telefonklappe)
$telefon = trim(($row->kontakt ?: '') . ' - ' . $row->telefonklappe);
$email = '';
if (defined('DOMAIN'))
$email = ($row->alias ?: $row->uid) . '@' . DOMAIN;
$personen[] = array(
'uid' => $row->uid,
'name' => $name,
'funktion' => isset($funktionen[$uid]) ? $funktionen[$uid] : '',
'telefon' => $telefon,
'email' => $email,
'ort' => $row->planbezeichnung,
'foto' => $row->foto
);
}
return $personen;
}
/**
* Decides whether the current user may see a DMS category.
*
* Same rule as cms/dms.php: an unlocked category is open to everyone. A locked one
* needs a granted group, or the category permission together with basis/dms.
*
* @param string $kategorie_kurzbz
* @return boolean|null null when the category does not exist
*/
/**
* Reads the tiefe parameter of a content component. 0 keeps the component to the unit
* or category it names, which is what a marker without the attribute means.
* Capped, because the depth drives a recursive walk and one query per level found.
*
* @return int
*/
private function _tiefe()
{
$tiefe = (int) $this->input->get('tiefe', TRUE);
if ($tiefe < 0)
return 0;
return min($tiefe, self::MAX_TIEFE);
}
private function _dmsKategorieErlaubt($kategorie_kurzbz)
{
$this->load->library('PermissionLib');
$this->load->model('content/Dms_model', 'DmsModel');
$zugriff = $this->DmsModel->getKategorieZugriff($kategorie_kurzbz, getAuthUID());
$zugriff = $this->getDataOrTerminateWithError($zugriff);
if (empty($zugriff))
return null;
$kategorie = current($zugriff);
if (!$kategorie->gesperrt || $kategorie->in_gruppe)
return true;
$erlaubt = $this->permissionlib->isBerechtigt('basis/dms');
if ($erlaubt && $kategorie->berechtigung_kurzbz != '')
$erlaubt = $this->permissionlib->isBerechtigt($kategorie->berechtigung_kurzbz);
return $erlaubt;
}
}
@@ -1,760 +0,0 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class CmsAdmin extends FHCAPI_Controller
{
const MAX_TREE_DEPTH = 50;
public function __construct()
{
parent::__construct([
'getTree' => ['basis/cms:r'],
'getSubtree' => ['basis/cms:r'],
'getContent' => ['basis/cms:r'],
'getContentsprache' => ['basis/cms:r'],
'getTemplates' => ['basis/cms:r'],
'getOrganisationseinheiten' => ['basis/cms:r'],
'getSprachen' => ['basis/cms:r'],
'getDmsKategorien' => ['basis/cms:r'],
'getMitarbeiter' => ['basis/cms:r'],
'getDmsKategorieDokumente' => ['basis/cms:r'],
'getUsage' => ['basis/cms:r'],
'getTreeMeta' => ['basis/cms:r'],
'getAncestors' => ['basis/cms:r'],
'getClickCount' => ['basis/cms:r'],
'getClickRanking' => ['basis/cms:r'],
'postContent' => ['basis/cms:rw'],
'postTranslation' => ['basis/cms:rw'],
'postVersion' => ['basis/cms:rw'],
'putProperties' => ['basis/cms:rw'],
'deleteContent' => ['basis/cms:rw'],
'deleteContentsprache' => ['basis/cms:rw']
]);
$this->load->library('CmsAdminLib');
$this->load->library('PermissionLib');
$this->load->model('content/Content_model', 'ContentModel');
$this->load->model('content/Contentsprache_model', 'ContentspracheModel');
$this->load->model('content/Contentchild_model', 'ContentchildModel');
$this->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
$this->load->model('content/Template_model', 'TemplateModel');
$this->loadPhrases(['global', 'cms']);
}
public function getTree()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('menu', 'Menu', 'required|in_list[content,news]');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$menu = $this->input->get('menu', TRUE);
$filter = $this->input->get('filter', TRUE);
$entitledResult = $this->cmsadminlib->getEntitledOe();
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult) ?? [];
$nodes = [];
if ($menu === 'news')
{
$newsResult = $this->ContentModel->getNewsContent();
if (!isError($newsResult) && getData($newsResult))
{
foreach (getData($newsResult) as $row)
{
$titel = '';
$csResult = $this->ContentspracheModel->getOne(
(int) $row->content_id, DEFAULT_LANGUAGE
);
if (!isError($csResult))
$titel = getData($csResult)->titel;
$node = new stdClass();
$node->content_id = (int) $row->content_id;
$node->titel = mb_substr($titel, 0, 15) . ' '
. date('d.m.Y', strtotime($row->insertamum));
$node->template_kurzbz = $row->template_kurzbz;
$node->oe_kurzbz = $row->oe_kurzbz;
$node->aktiv = ($row->aktiv === 't' || $row->aktiv === true);
$node->entitled = false;
$node->groups = [];
$node->children = [];
$nodes[] = $node;
}
}
}
else
{
if (!empty($filter))
{
$clean = str_replace(['!', '.', '?', ','], '', $filter);
$searchItems = preg_split('/\s+/', $clean, -1, PREG_SPLIT_NO_EMPTY);
$rootsResult = $this->ContentModel->searchCms($searchItems);
}
else
{
$rootsResult = $this->ContentModel->getRootContent();
}
if (!isError($rootsResult) && getData($rootsResult))
{
$visited = [];
foreach (getData($rootsResult) as $row)
{
$node = $this->buildTreeNode(
(int) $row->content_id, 0, $visited
);
if ($node !== null)
$nodes[] = $node;
}
}
}
$allIds = [];
$this->collectContentIds($nodes, $allIds);
$groupMap = [];
if (!empty($allIds))
{
$gResult = $this->ContentgruppeModel->getGruppenForContents($allIds);
if (!isError($gResult))
$groupMap = getData($gResult);
}
$this->applyMeta($nodes, $entitledOe, $groupMap);
$result = [];
foreach ($nodes as $node)
{
$pruned = $this->pruneUnentitled($node);
if ($pruned !== null)
$result[] = $pruned;
}
$this->terminateWithSuccess($result);
}
// One subtree in the shape of a getTree root node. Lets the client replace a branch
// instead of rebuilding the whole tree after a change.
// The visited map starts empty here, so a node with several parents can show up twice
// until the next full reload. Rebuilding the whole tree on every change costs more.
public function getSubtree()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = (int) $this->input->get('content_id', TRUE);
$entitledResult = $this->cmsadminlib->getEntitledOe();
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult);
$visited = [];
$node = $this->buildTreeNode($content_id, 0, $visited);
// Deleted meanwhile. The client drops the branch.
if ($node === null)
$this->terminateWithSuccess(null);
$nodes = [$node];
$allIds = [];
$this->collectContentIds($nodes, $allIds);
$groupMap = [];
if (!empty($allIds))
{
$gResult = $this->ContentgruppeModel->getGruppenForContents($allIds);
if (!isError($gResult))
$groupMap = getData($gResult);
}
$this->applyMeta($nodes, $entitledOe, $groupMap);
$this->terminateWithSuccess($this->pruneUnentitled($node));
}
public function getContent()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$entitledResult = $this->cmsadminlib->isEntitledForContent($content_id);
$entitled = $this->getDataOrTerminateWithError($entitledResult);
if (!$entitled)
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDiesenEintrag'));
$this->ContentModel->resetQuery();
$contentResult = $this->ContentModel->load($content_id);
$contentData = $this->getDataOrTerminateWithError($contentResult);
if (empty($contentData))
$this->terminateWithError($this->p->t('cms', 'contentNichtGefunden'));
$content = $contentData[0];
$langResult = $this->ContentspracheModel->getLanguages($content_id);
$languages = $this->getDataOrTerminateWithError($langResult);
$versions = new stdClass();
foreach ($languages as $sprache)
{
$vResult = $this->ContentspracheModel->getVersions($content_id, $sprache);
if (!isError($vResult) && getData($vResult))
{
$versions->$sprache = array_map(function ($v) {
return (int) $v->version;
}, getData($vResult));
}
}
$result = new stdClass();
$result->content_id = (int) $content->content_id;
$result->template_kurzbz = $content->template_kurzbz;
$result->oe_kurzbz = $content->oe_kurzbz;
$result->aktiv = ($content->aktiv === 't' || $content->aktiv === true);
$result->menu_open = ($content->menu_open === 't' || $content->menu_open === true);
$result->beschreibung = $content->beschreibung ?: '';
$result->entitled = true;
$result->languages = $languages;
$result->versions = $versions;
// Tells the CIS4 preview whether this template renders there at all.
$cis4Result = $this->TemplateModel->hasCis4Stylesheet($content->template_kurzbz);
$result->has_cis4_stylesheet = !isError($cis4Result) && getData($cis4Result);
$this->terminateWithSuccess($result);
}
public function getContentsprache()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$version = $this->input->get('version', TRUE);
$result = $this->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($result))
$this->terminateWithError($this->p->t('cms', 'versionNichtGefunden'));
$data = getData($result);
$data->sichtbar = ($data->sichtbar === 't' || $data->sichtbar === true);
$this->terminateWithSuccess($data);
}
// The tree lists a search hit out of its place, so a generic title carries no context.
// This answers where each one sits. One walk for the whole list, not one per node.
const MAX_ANCESTOR_TIEFE = 12;
/**
* Ancestors of the given contents, keyed by content_id and ordered from the top down.
*/
public function getAncestors()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_ids', 'Content IDs', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$ids = [];
foreach (explode(',', $this->input->get('content_ids', TRUE)) as $teil)
{
$teil = trim($teil);
if (is_numeric($teil))
$ids[] = (int) $teil;
}
$ids = array_values(array_unique($ids));
if (empty($ids))
$this->terminateWithSuccess(new stdClass());
$result = $this->ContentchildModel->getAncestors(
$ids, self::MAX_ANCESTOR_TIEFE, DEFAULT_LANGUAGE
);
$rows = $this->getDataOrTerminateWithError($result);
// Same rule as the tree: an entry of an organisational unit the editor is not
// entitled for stays readable but cannot be opened.
$entitledResult = $this->cmsadminlib->getEntitledOe();
$entitledOe = isError($entitledResult) ? [] : getData($entitledResult) ?? [];
// The query orders by the distance, farthest first, so appending keeps the path
// reading from the top down.
$pfade = [];
foreach ((array) $rows as $row)
{
$start = (int) $row->start_id;
if (!isset($pfade[$start]))
$pfade[$start] = [];
$pfade[$start][] = [
'content_id' => (int) $row->content_id,
'titel' => $row->titel,
'tiefe' => (int) $row->tiefe,
'entitled' => in_array($row->oe_kurzbz, $entitledOe)
];
}
$this->terminateWithSuccess($pfade ?: new stdClass());
}
/**
* Per content metadata for the tree filter, keyed by content_id.
* One query for the whole tree, so the client filters and sorts without a round trip.
*/
public function getTreeMeta()
{
$result = $this->ContentModel->getTreeMeta(getAuthUID(), DEFAULT_LANGUAGE);
$rows = $this->getDataOrTerminateWithError($result);
$meta = [];
foreach ((array) $rows as $row)
{
$meta[(int) $row->content_id] = [
'childcount' => (int) $row->childcount,
'insertamum' => $row->insertamum,
'updateamum' => $row->updateamum,
'mine' => ($row->mine === 't' || $row->mine === true),
'contentlength' => (int) $row->contentlength
];
}
$this->terminateWithSuccess($meta);
}
/**
* Two gates guard every click statistics request: the config flag switches the feature
* off for everybody, the admin right decides who may read the numbers.
*/
private function checkClickstatsAccess()
{
$this->config->load('cms');
if (!$this->config->item('clickstats_enabled'))
$this->terminateWithError($this->p->t('cms', 'klickstatistikDeaktiviert'));
if (!$this->permissionlib->isBerechtigt('admin'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
}
/**
* Period of a click statistics request. 0 counts the whole log.
* Call it after checkClickstatsAccess, which loads the config.
* @return array months and the start date, null for the whole log
*/
private function clickstatsPeriod()
{
$months = $this->input->get('months');
if ($months === null || $months === '')
$months = $this->config->item('clickstats_months');
$months = (int) $months;
$since = ($months > 0)
? date('Y-m-d H:i:s', strtotime('-' . $months . ' months'))
: null;
return [$months, $since];
}
/**
* Views of one content. One counting query, so the tab loads it right away.
* Empty if LOG_CONTENT is off.
*/
public function getClickCount()
{
$this->checkClickstatsAccess();
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('months', 'Monate', 'is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('system/Webservicelog_model', 'WebservicelogModel');
list($months, $since) = $this->clickstatsPeriod();
$result = $this->WebservicelogModel->getContentClickCount(
(int) $this->input->get('content_id', TRUE), $since
);
$this->terminateWithSuccess([
'since' => $since,
'months' => $months,
'own' => $this->getDataOrTerminateWithError($result)
]);
}
/**
* The ranking of the most viewed contents. It groups the whole log and resolves a
* title per row, which takes seconds, so the tab fetches it only on request.
* Empty if LOG_CONTENT is off.
*/
public function getClickRanking()
{
$this->checkClickstatsAccess();
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('months', 'Monate', 'is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('system/Webservicelog_model', 'WebservicelogModel');
list($months, $since) = $this->clickstatsPeriod();
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->WebservicelogModel->getContentClickCounts(
$since, $sprache, (int) $this->config->item('clickstats_limit')
);
$rows = $this->getDataOrTerminateWithError($result);
$ranked = [];
foreach ((array) $rows as $row)
{
$ranked[] = [
'content_id' => (int) $row->content_id,
'titel' => $row->titel,
'hits' => (int) $row->hits
];
}
$this->terminateWithSuccess([
'since' => $since,
'months' => $months,
'ranked' => $ranked
]);
}
public function getTemplates()
{
$this->TemplateModel->addSelect('template_kurzbz, bezeichnung');
$this->TemplateModel->addOrder('bezeichnung');
$result = $this->TemplateModel->loadWhere([]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getOrganisationseinheiten()
{
$this->load->model(
'organisation/Organisationseinheit_model', 'OrganisationseinheitModel'
);
$this->OrganisationseinheitModel->addSelect(
'oe_kurzbz, bezeichnung, organisationseinheittyp_kurzbz, aktiv'
);
$this->OrganisationseinheitModel->addOrder('bezeichnung');
$result = $this->OrganisationseinheitModel->loadWhere([]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getSprachen()
{
$this->load->model('system/Sprache_model', 'SpracheModel');
$this->SpracheModel->addSelect('sprache, bezeichnung');
$result = $this->SpracheModel->loadWhere(['content' => true]);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getUsage()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$result = $this->ContentModel->getUsage($content_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postContent()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('parent_content_id', 'Parent', 'is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$parent = $this->input->post('parent_content_id');
if (empty($parent))
$parent = null;
$result = $this->cmsadminlib->createContent($parent);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postTranslation()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
$this->form_validation->set_rules('target_sprache', 'Target Sprache', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->cmsadminlib->createTranslation(
$this->input->post('content_id'),
$this->input->post('sprache'),
$this->input->post('version'),
$this->input->post('target_sprache')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postVersion()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'i'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->cmsadminlib->createVersion(
$this->input->post('content_id'),
$this->input->post('sprache')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function putProperties()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
$this->form_validation->set_rules('template_kurzbz', 'Template', 'required');
$this->form_validation->set_rules('oe_kurzbz', 'OE', 'required');
$this->form_validation->set_rules('titel', 'Titel', 'required|max_length[256]');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$daten = [
'content_id' => $this->input->post('content_id'),
'sprache' => $this->input->post('sprache'),
'version' => $this->input->post('version'),
'template_kurzbz' => $this->input->post('template_kurzbz'),
'oe_kurzbz' => $this->input->post('oe_kurzbz'),
'titel' => $this->input->post('titel'),
'beschreibung' => $this->input->post('beschreibung') ?: '',
'aktiv' => filter_var($this->input->post('aktiv'), FILTER_VALIDATE_BOOLEAN),
'menu_open' => filter_var($this->input->post('menu_open'), FILTER_VALIDATE_BOOLEAN),
'sichtbar' => filter_var($this->input->post('sichtbar'), FILTER_VALIDATE_BOOLEAN)
];
$result = $this->cmsadminlib->saveProperties($daten);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteContent()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'd'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->cmsadminlib->deleteContent($this->input->post('content_id'));
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteContentsprache()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'd'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->validateContentSpracheVersion();
$result = $this->cmsadminlib->deleteVersion(
$this->input->post('content_id'),
$this->input->post('sprache'),
$this->input->post('version')
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
// --- Private helpers ---
private function validateContentSpracheVersion()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
}
private function buildTreeNode($content_id, $depth, &$visited)
{
if ($depth > self::MAX_TREE_DEPTH || isset($visited[$content_id]))
return null;
$visited[$content_id] = true;
$this->ContentModel->resetQuery();
$contentResult = $this->ContentModel->load($content_id);
if (isError($contentResult) || empty(getData($contentResult)))
return null;
$content = getData($contentResult)[0];
$titel = '';
$csResult = $this->ContentspracheModel->getOne($content_id, DEFAULT_LANGUAGE);
if (!isError($csResult))
$titel = getData($csResult)->titel;
$childrenResult = $this->ContentchildModel->getChilds($content_id, DEFAULT_LANGUAGE);
$children = [];
if (!isError($childrenResult) && getData($childrenResult))
{
foreach (getData($childrenResult) as $child)
{
$childNode = $this->buildTreeNode(
(int) $child->child_content_id, $depth + 1, $visited
);
if ($childNode !== null)
$children[] = $childNode;
}
}
$node = new stdClass();
$node->content_id = (int) $content_id;
$node->titel = $titel;
$node->template_kurzbz = $content->template_kurzbz;
$node->oe_kurzbz = $content->oe_kurzbz;
$node->aktiv = ($content->aktiv === 't' || $content->aktiv === true);
$node->entitled = false;
$node->groups = [];
$node->children = $children;
return $node;
}
private function collectContentIds($nodes, &$ids)
{
foreach ($nodes as $node)
{
$ids[] = $node->content_id;
if (!empty($node->children))
$this->collectContentIds($node->children, $ids);
}
}
private function applyMeta(&$nodes, $entitledOe, $groupMap)
{
foreach ($nodes as $node)
{
$node->entitled = in_array($node->oe_kurzbz, $entitledOe);
$node->groups = isset($groupMap[$node->content_id])
? $groupMap[$node->content_id]
: [];
if (!empty($node->children))
$this->applyMeta($node->children, $entitledOe, $groupMap);
}
}
private function pruneUnentitled($node)
{
$kept = [];
foreach ($node->children as $child)
{
$pruned = $this->pruneUnentitled($child);
if ($pruned !== null)
$kept[] = $pruned;
}
$node->children = $kept;
if ($node->entitled || !empty($node->children))
return $node;
return null;
}
/**
* Lists the DMS categories for the contentcomponent insert dialog.
*/
public function getDmsKategorien()
{
$this->load->model('content/Dms_model', 'DmsModel');
$result = $this->DmsModel->getKategorien();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
/**
* Lists active staff for the contentcomponent insert dialog.
* Names only. See Mitarbeiter_model::getAktivesPersonalNamen().
*/
public function getMitarbeiter()
{
$this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel');
$result = $this->MitarbeiterModel->getAktivesPersonalNamen();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
/**
* Lists the documents of one DMS category for the contentcomponent insert dialog.
*/
public function getDmsKategorieDokumente()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('kategorie_kurzbz', 'DMS-Kategorie', 'required');
if ($this->form_validation->run() == FALSE) $this->terminateWithValidationErrors($this->form_validation->error_array());
$this->load->model('content/Dms_model', 'DmsModel');
$result = $this->DmsModel->getKategorieDokumenteAlle($this->input->get('kategorie_kurzbz', TRUE));
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result) ?: array());
}
}
@@ -1,195 +0,0 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class CmsAdminInhalt extends FHCAPI_Controller
{
public function __construct()
{
parent::__construct([
'getFormSchema' => ['basis/cms:r'],
'getFormData' => ['basis/cms:r'],
'getLock' => ['basis/cms:r'],
'getVersions' => ['basis/cms:r'],
'putFormData' => ['basis/cms:rw'],
'postLock' => ['basis/cms:rw'],
'deleteLock' => ['basis/cms:rw'],
'deleteLockForced' => ['basis/cms:rw']
]);
$this->load->library('CmsAdminLib');
$this->load->library('XsdSchemaLib');
$this->load->library('PermissionLib');
$this->load->model('content/Contentsprache_model', 'ContentspracheModel');
$this->load->model('content/Template_model', 'TemplateModel');
$this->loadPhrases(['global', 'cms']);
}
public function getFormSchema()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('template_kurzbz', 'Template', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$template_kurzbz = $this->input->get('template_kurzbz', TRUE);
$this->TemplateModel->resetQuery();
$templateResult = $this->TemplateModel->load($template_kurzbz);
$templateData = $this->getDataOrTerminateWithError($templateResult);
if (empty($templateData))
$this->terminateWithError($this->p->t('cms', 'vorlageNichtGefunden'));
$schemaResult = $this->xsdschemalib->parseSchema(
$templateData[0]->xsd, $template_kurzbz
);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($schemaResult));
}
public function getFormData()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$version = $this->input->get('version', TRUE);
$versionResult = $this->ContentspracheModel->getOne($content_id, $sprache, $version);
$row = $this->getDataOrTerminateWithError($versionResult);
$this->load->model('content/Content_model', 'ContentModel');
$this->ContentModel->resetQuery();
$contentResult = $this->ContentModel->load($content_id);
$contentData = $this->getDataOrTerminateWithError($contentResult);
if (empty($contentData))
$this->terminateWithError($this->p->t('cms', 'contentNichtGefunden'));
$this->TemplateModel->resetQuery();
$templateResult = $this->TemplateModel->load($contentData[0]->template_kurzbz);
$templateData = $this->getDataOrTerminateWithError($templateResult);
if (empty($templateData))
$this->terminateWithError($this->p->t('cms', 'vorlageNichtGefunden'));
$schemaResult = $this->xsdschemalib->parseSchema(
$templateData[0]->xsd, $contentData[0]->template_kurzbz
);
$schema = $this->getDataOrTerminateWithError($schemaResult);
$valuesResult = $this->xsdschemalib->extractValues($row->content, $schema);
$values = $this->getDataOrTerminateWithError($valuesResult);
$lockResult = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
$lock = $this->getDataOrTerminateWithError($lockResult);
$this->terminateWithSuccess([
'schema' => $schema,
'values' => $values,
'sperre' => $lock
]);
}
public function getLock()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$version = $this->input->get('version', TRUE);
$result = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getVersions()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
$result = $this->ContentspracheModel->getVersions($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function putFormData()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('sprache', 'Sprache', 'required');
$this->form_validation->set_rules('version', 'Version', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->post('content_id');
$sprache = $this->input->post('sprache');
$version = $this->input->post('version');
$values = $this->input->post('values');
if (is_string($values))
$values = json_decode($values, true);
if (!is_array($values))
$values = [];
$lockResult = $this->cmsadminlib->getLockState($content_id, $sprache, $version);
$lock = $this->getDataOrTerminateWithError($lockResult);
if (!$lock['own'])
$this->terminateWithError($this->p->t('cms', 'speichernNurMitSperre'));
$result = $this->cmsadminlib->saveContentXml($content_id, $sprache, $version, $values);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postLock()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion'));
$contentsprache_id = $this->input->post('contentsprache_id');
$result = $this->cmsadminlib->lock($contentsprache_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteLock()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion'));
$contentsprache_id = $this->input->post('contentsprache_id');
$result = $this->cmsadminlib->releaseOwnLock($contentsprache_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function deleteLockForced()
{
if (!$this->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigungFuerDieseAktion'));
$contentsprache_id = $this->input->post('contentsprache_id');
$result = $this->cmsadminlib->forceRelease($contentsprache_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
}
@@ -1,265 +0,0 @@
<?php
if (! defined('BASEPATH')) exit('No direct script access allowed');
class CmsAdminStruktur extends FHCAPI_Controller
{
public function __construct()
{
parent::__construct([
'getGruppen' => ['basis/cms:r'],
'getAllGruppen' => ['basis/cms:r'],
'getChilds' => ['basis/cms:r'],
'getParents' => ['basis/cms:r'],
'getPossibleChilds' => ['basis/cms:r'],
'postGruppe' => ['basis/cms:rw'],
'deleteGruppe' => ['basis/cms:rw'],
'postChild' => ['basis/cms:rw'],
'deleteChild' => ['basis/cms:rw'],
'putChildSort' => ['basis/cms:rw'],
'putChildOrder' => ['basis/cms:rw']
]);
$this->load->library('PermissionLib');
$this->load->model('content/Content_model', 'ContentModel');
$this->load->model('content/Contentchild_model', 'ContentchildModel');
$this->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
$this->loadPhrases(['global', 'cms']);
}
public function getGruppen()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$result = $this->ContentgruppeModel->getGruppen($content_id);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getAllGruppen()
{
$result = $this->ContentgruppeModel->getAllGruppen();
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getChilds()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->ContentchildModel->getChilds($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getParents()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->ContentchildModel->getParents($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function getPossibleChilds()
{
$this->load->library('form_validation');
$this->form_validation->set_data($_GET);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->get('content_id', TRUE);
$sprache = $this->input->get('sprache', TRUE);
if (empty($sprache))
$sprache = DEFAULT_LANGUAGE;
$result = $this->ContentModel->getPossibleChilds($content_id, $sprache);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
public function postGruppe()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('gruppe_kurzbz', 'Gruppe', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentgruppeModel->insert([
'content_id' => $this->input->post('content_id'),
'gruppe_kurzbz' => $this->input->post('gruppe_kurzbz'),
'insertamum' => date('Y-m-d H:i:s'),
'insertvon' => getAuthUID()
]);
if (isError($result))
{
$this->terminateWithError($this->p->t('cms', 'gruppeBereitsZugeordnet'));
}
$this->terminateWithSuccess(true);
}
public function deleteGruppe()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('gruppe_kurzbz', 'Gruppe', 'required');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentgruppeModel->delete([
$this->input->post('gruppe_kurzbz'),
$this->input->post('content_id')
]);
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess(true);
}
public function postChild()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
$this->form_validation->set_rules('child_content_id', 'Child Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$content_id = $this->input->post('content_id');
$sortResult = $this->ContentchildModel->getMaxSort($content_id);
$sort = $this->getDataOrTerminateWithError($sortResult) + 1;
$result = $this->ContentchildModel->insert([
'content_id' => $content_id,
'child_content_id' => $this->input->post('child_content_id'),
'sort' => $sort,
'insertamum' => date('Y-m-d H:i:s'),
'insertvon' => getAuthUID()
]);
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess(true);
}
public function deleteChild()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('contentchild_id', 'Contentchild ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$result = $this->ContentchildModel->delete($this->input->post('contentchild_id'));
$this->getDataOrTerminateWithError($result);
$this->terminateWithSuccess(true);
}
public function putChildSort()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('contentchild_id', 'Contentchild ID', 'required|is_natural');
$this->form_validation->set_rules('direction', 'Direction', 'required|in_list[up,down]');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$direction = $this->input->post('direction');
$result = $this->ContentchildModel->swapSort(
$this->input->post('contentchild_id'),
$direction
);
if (isError($result))
{
// The child already sits at the end it was moved towards. That is a normal
// answer, not a failure, and the direction decides the wording.
if (getError($result) === Contentchild_model::NO_NEIGHBOUR)
{
$this->terminateWithError($this->p->t('cms',
$direction === 'up' ? 'bereitsGanzOben' : 'bereitsGanzUnten'));
}
$this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen'));
}
$this->terminateWithSuccess(true);
}
// Writes the whole order at once. The drag and drop moves a child over any distance,
// and putChildSort would need one request per position.
public function putChildOrder()
{
if (!$this->permissionlib->isBerechtigt('basis/cms', 'u'))
$this->terminateWithError($this->p->t('cms', 'keineBerechtigung'));
$this->load->library('form_validation');
$this->form_validation->set_data($_POST);
$this->form_validation->set_rules('content_id', 'Content ID', 'required|is_natural');
if ($this->form_validation->run() == FALSE)
$this->terminateWithValidationErrors($this->form_validation->error_array());
$ids = $this->input->post('contentchild_ids');
if (is_string($ids))
$ids = json_decode($ids, true);
if (!is_array($ids) || empty($ids))
$this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen'));
$result = $this->ContentchildModel->setSortOrder(
$this->input->post('content_id'),
$ids
);
if (isError($result))
$this->terminateWithError($this->p->t('cms', 'sortierungFehlgeschlagen'));
$this->terminateWithSuccess(true);
}
}
@@ -101,6 +101,9 @@ class Widget extends FHCAPI_Controller
$tmpsetup = json_decode($widget->setup);
$tmpsetup->file = absoluteJsImportUrl($tmpsetup->file);
$widget->setup = $tmpsetup;
$widget->permitted = empty($widget->berechtigung_kurzbz)
|| $this->permissionlib->isBerechtigt($widget->berechtigung_kurzbz);
return $widget;
}, $widgets);
@@ -21,7 +21,7 @@ class NotizAnrechnung extends Notiz_Controller
// Load language phrases
$this->loadPhrases([
'ui'
'ui', 'lehre'
]);
}
@@ -25,7 +25,7 @@ class Anrechnungen extends FHCAPI_Controller
// Load language phrases
$this->loadPhrases([
'ui', 'lehre'
'ui', 'lehre', 'anrechnung'
]);
// Load models
@@ -45,6 +45,12 @@ class Anrechnungen extends FHCAPI_Controller
{
$this->load->model('education/Anrechnungbegruendung_model', 'AnrechnungbegrueundungsModel');
// Begruendung in the users language, German name if no translation exists
$language_index = getUserLanguage() == 'German' ? 1 : 2;
$this->AnrechnungbegrueundungsModel->addSelect(
'begruendung_id, COALESCE(bezeichnung_mehrsprachig[' . $language_index . '], bezeichnung) AS bezeichnung',
false
);
$result = $this->AnrechnungbegrueundungsModel->load();
if (isError($result)) {
$this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL);
@@ -118,22 +124,22 @@ class Anrechnungen extends FHCAPI_Controller
: null;
$this->form_validation->set_rules('lehrveranstaltung_id', 'Lehrveranstaltung_id', 'required', [
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => 'Lehrveranstaltung'])
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => $this->p->t('lehre', 'lehrveranstaltung')])
]);
$this->form_validation->set_rules('begruendung', 'Begruendung', 'required', [
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => 'Begruendung'])
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => $this->p->t('anrechnung', 'begruendung')])
]);
if($_POST['begruendung'] == 2)
{
$this->form_validation->set_rules('lehrveranstaltung_id_kompatibel', 'Lehrveranstaltung_id', 'required', [
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => 'Lehrveranstaltung Kompatibel'])
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => $this->p->t('anrechnung', 'lehrveranstaltung_bez_kompatibel')])
]);
}
$this->form_validation->set_rules('genehmigtVon', 'GenehmigtVon', 'required', [
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => 'GenehmigtVon'])
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => $this->p->t('anrechnung', 'genehmigtVon')])
]);
if ($this->form_validation->run() == false)
@@ -190,22 +196,22 @@ class Anrechnungen extends FHCAPI_Controller
$_POST['genehmigtVon'] = (isset($formData['genehmigt_von']) && !empty($formData['genehmigt_von'])) ? $formData['genehmigt_von'] : null;
$this->form_validation->set_rules('lehrveranstaltung_id', 'Lehrveranstaltung_id', 'required', [
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => 'Lehrveranstaltung'])
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => $this->p->t('lehre', 'lehrveranstaltung')])
]);
$this->form_validation->set_rules('begruendung', 'Begruendung', 'required', [
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => 'Begruendung'])
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => $this->p->t('anrechnung', 'begruendung')])
]);
if($_POST['begruendung'] == 2)
{
$this->form_validation->set_rules('lehrveranstaltung_id_kompatibel', 'Lehrveranstaltung_id', 'required', [
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => 'Lehrveranstaltung Kompatibel'])
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => $this->p->t('anrechnung', 'lehrveranstaltung_bez_kompatibel')])
]);
}
$this->form_validation->set_rules('genehmigtVon', 'GenehmigtVon', 'required', [
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => 'GenehmigtVon'])
'required' => $this->p->t('ui', 'error_fieldRequired', ['field' => $this->p->t('anrechnung', 'genehmigtVon')])
]);
if ($this->form_validation->run() == false)
@@ -168,12 +168,15 @@ class AnrechnungJob extends JOB_Controller
// Get all yesterdays Anrechnungen, that did not process further than first status
// (If Anrechnung is new, but STGL already started the process yesterday,
// he does not need to be informed about this new Anrechnung anymore)
$this->AnrechnungModel->addSelect('anrechnung_id, studiensemester_kurzbz, lv.studiengang_kz, lv.bezeichnung, vorname, nachname');
// Studiengang of the prestudent, not of the LV (same source as the permission checks)
$this->AnrechnungModel->addSelect('anrechnung_id, studiensemester_kurzbz, prestudent.studiengang_kz, lv.bezeichnung, vorname, nachname');
$this->AnrechnungModel->addJoin('lehre.tbl_lehrveranstaltung lv', 'lehrveranstaltung_id');
$this->AnrechnungModel->addJoin('public.tbl_student student', 'prestudent_id');
$this->AnrechnungModel->addJoin('public.tbl_benutzer benutzer', 'ON (benutzer.uid = student.student_uid)');
$this->AnrechnungModel->addJoin('public.tbl_person person', 'person_id');
$this->AnrechnungModel->addOrder('lv.studiengang_kz, lv.bezeichnung');
// After tbl_person: tbl_prestudent also has person_id, that would make USING (person_id) ambiguous
$this->AnrechnungModel->addJoin('public.tbl_prestudent prestudent', 'ON (prestudent.prestudent_id = student.prestudent_id)');
$this->AnrechnungModel->addOrder('prestudent.studiengang_kz, lv.bezeichnung');
$result = $this->AnrechnungModel->loadWhere(
'(lehre.tbl_anrechnung.insertamum)::date = (NOW() - INTERVAL \'24 HOURS\')::DATE
@@ -128,7 +128,7 @@ class approveAnrechnungDetail extends Auth_Controller
// Validate data
if (isEmptyArray($data))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
// Get STGLs person data
@@ -159,7 +159,7 @@ class approveAnrechnungDetail extends Auth_Controller
}
else
{
return $this->outputJsonError('Es wurden keine Anrechnungen genehmigt.');
return $this->outputJsonError($this->p->t('anrechnung', 'keineAnrechnungenGenehmigt'));
}
}
@@ -173,7 +173,7 @@ class approveAnrechnungDetail extends Auth_Controller
// Validate data
if (isEmptyArray($data))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
// Get STGLs person data
@@ -217,7 +217,7 @@ class approveAnrechnungDetail extends Auth_Controller
if(isEmptyString($anrechnung_id))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
$retval = array();
@@ -225,7 +225,7 @@ class approveAnrechnungDetail extends Auth_Controller
// Check if Anrechnungs-LV has lector
if (!$this->anrechnunglib->LVhasLector($anrechnung_id))
{
$this->terminateWithJsonError('LV has no lector');
$this->terminateWithJsonError($this->p->t('anrechnung', 'lvHatKeineLektoren'));
}
// Get Fachbereichsleitung or LV Leitung.
@@ -258,13 +258,13 @@ class approveAnrechnungDetail extends Auth_Controller
if ($empfehlungsanfrage_an == '')
{
$this->terminateWithJsonError(
"Empfehlung wurde nicht angefordert,\nDer LV sind keine LektorInnen zugeteilt."
$this->p->t('anrechnung', 'empfehlungNichtAngefordert') . "\n" . $this->p->t('anrechnung', 'lvHatKeineLektoren')
);
}
if (isEmptyArray($retval))
{
$this->terminateWithJsonError("Empfehlung wurde nicht angefordert");
$this->terminateWithJsonError($this->p->t('anrechnung', 'empfehlungNichtAngefordert'));
}
else
{
@@ -285,6 +285,12 @@ class approveAnrechnungDetail extends Auth_Controller
$this->terminateWithJsonError($this->p->t('ui', 'errorFelderFehlen'));
}
// Exit if user has no write permission for the Studiengang of the Anrechnung
if (!$this->anrechnunglib->isBerechtigtForAnrechnung(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 'suid', $anrechnung_id))
{
$this->terminateWithJsonError($this->p->t('ui', 'keineBerechtigung'));
}
// Delete last status approved / rejected.
// If last status is 'approved', Genehmigung is resetted.
$result = $this->AnrechnungModel->withdrawApprovement($anrechnung_id);
@@ -313,6 +319,12 @@ class approveAnrechnungDetail extends Auth_Controller
show_error('Wrong parameter.');
}
// Exit if user has no write permission for the Studiengang of the Anrechnung
if (!$this->anrechnunglib->isBerechtigtForAnrechnung(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 'suid', $anrechnung_id))
{
return $this->outputJsonError($this->p->t('ui', 'keineBerechtigung'));
}
// Get boolean empfehlung of given Anrechnung
if (!$result = getData($this->AnrechnungModel->load($anrechnung_id))[0])
{
@@ -333,7 +345,7 @@ class approveAnrechnungDetail extends Auth_Controller
// Return if Anrechnung was not waiting for recommendation or if Anrechnung has already been recommended
if ($last_status != self::ANRECHNUNGSTATUS_PROGRESSED_BY_LEKTOR || !is_null($empfehlung))
{
return $this->outputJsonError('No recommendation to withdraw.');
return $this->outputJsonError($this->p->t('anrechnung', 'keineEmpfehlungZumZuruecknehmen'));
}
// Reset status to 'inProgressDP'
@@ -341,7 +353,7 @@ class approveAnrechnungDetail extends Auth_Controller
if (isError($result))
{
return $this->outputJsonError('Could not withdraw this application.');
return $this->outputJsonError($this->p->t('anrechnung', 'antragNichtZurueckgenommen'));
}
// Success output to AJAX
@@ -359,7 +371,20 @@ class approveAnrechnungDetail extends Auth_Controller
// Validate data
if (isEmptyString($anrechnung_id))
{
$this->terminateWithJsonError($this->p->t('ui', 'systemFehler'));
$this->terminateWithJsonError($this->p->t('ui', 'systemfehler'));
}
// Exit if user has no write permission for the Studiengang of the Anrechnung
if (!$this->anrechnunglib->isBerechtigtForAnrechnung(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 'suid', $anrechnung_id))
{
$this->terminateWithJsonError($this->p->t('ui', 'keineBerechtigung'));
}
// Exit if the posted Notiz is not an Empfehlungsnotiz of this Anrechnung
$result = $this->NotizModel->getNotizByAnrechnung($anrechnung_id, self::ANRECHNUNG_NOTIZTITEL_EMPFEHLUNGSNOTIZ_BY_STGL);
if (!isEmptyString($notiz_id) && !in_array($notiz_id, array_column(hasData($result) ? getData($result) : array(), 'notiz_id')))
{
$this->terminateWithJsonError($this->p->t('ui', 'keineBerechtigung'));
}
// Save Empfehlungstext
@@ -423,14 +448,8 @@ class approveAnrechnungDetail extends Auth_Controller
show_error('Failed loading Anrechnung');
}
$result = $this->LehrveranstaltungModel->loadWhere(array(
'lehrveranstaltung_id' => getData($result)[0]->lehrveranstaltung_id
));
$studiengang_kz = getData($result)[0]->studiengang_kz;
// Check if user is entitled
if (!$this->permissionlib->isBerechtigt(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 's', $studiengang_kz))
// Check if user is entitled for the Studiengang of the Anrechnung
if (!$this->anrechnunglib->isBerechtigtForAnrechnung(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 's', $anrechnung_id))
{
show_error('You are not entitled to read this page');
}
@@ -449,14 +468,8 @@ class approveAnrechnungDetail extends Auth_Controller
show_error('Failed retrieving Anrechnung');
}
$result = $this->LehrveranstaltungModel->loadWhere(array(
'lehrveranstaltung_id' => $result->lehrveranstaltung_id
));
$studiengang_kz = getData($result)[0]->studiengang_kz;
// Check if user is entitled
if (!$this->permissionlib->isBerechtigt(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 's', $studiengang_kz))
// Check if user is entitled for the Studiengang of the Anrechnung
if (!$this->anrechnunglib->isBerechtigtForAnrechnung(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 's', $result->anrechnung_id))
{
show_error('You are not entitled to read this document');
}
@@ -123,7 +123,7 @@ class approveAnrechnungUebersicht extends Auth_Controller
// Validate data
if (isEmptyArray($data))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
$json = array(
@@ -154,7 +154,7 @@ class approveAnrechnungUebersicht extends Auth_Controller
}
else
{
return $this->outputJsonError('Es wurden keine Anrechnungen genehmigt.');
return $this->outputJsonError($this->p->t('anrechnung', 'keineAnrechnungenGenehmigt'));
}
}
@@ -168,7 +168,7 @@ class approveAnrechnungUebersicht extends Auth_Controller
// Validate data
if (isEmptyArray($data))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
// Reject Anrechnung
@@ -191,7 +191,7 @@ class approveAnrechnungUebersicht extends Auth_Controller
}
else
{
return $this->outputJsonError('Es wurden keine Anrechnungen genehmigt.');
return $this->outputJsonError($this->p->t('anrechnung', 'keineAnrechnungenAbgelehnt'));
}
}
@@ -204,7 +204,7 @@ class approveAnrechnungUebersicht extends Auth_Controller
if(isEmptyArray($data))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
$retval = array();
@@ -254,10 +254,10 @@ class approveAnrechnungUebersicht extends Auth_Controller
{
if ($counter > 0)
{
$this->terminateWithJsonError('Bei '. $counter.' LV sind keine LektorInnen zugeteilt.');
$this->terminateWithJsonError($this->p->t('anrechnung', 'lvsOhneLektoren', array($counter)));
}
$this->terminateWithJsonError('Es wurden keine Empfehlungen angefordert');
$this->terminateWithJsonError($this->p->t('anrechnung', 'keineEmpfehlungenAngefordert'));
}
$this->outputJsonSuccess($retval);
@@ -312,19 +312,8 @@ class approveAnrechnungUebersicht extends Auth_Controller
show_error('Failed retrieving Anrechnung');
}
$result = $this->LehrveranstaltungModel->loadWhere(array(
'lehrveranstaltung_id' => $result->lehrveranstaltung_id
));
if(!$result = getData($result)[0])
{
show_error('Failed loading Lehrveranstaltung');
}
$studiengang_kz = $result->studiengang_kz;
// Check if user is entitled
if (!$this->permissionlib->isBerechtigt(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 's', $studiengang_kz))
// Check if user is entitled for the Studiengang of the Anrechnung
if (!$this->anrechnunglib->isBerechtigtForAnrechnung(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 's', $result->anrechnung_id))
{
show_error('You are not entitled to read this document');
}
@@ -76,6 +76,13 @@ class CreateAnrechnung extends Auth_Controller
// Get Anrechnungsbegruendungen
$this->load->model('education/Anrechnungbegruendung_model', 'AnrechnungbegruendungModel');
// Begruendung in the users language, German name if no translation exists
$language_index = getUserLanguage() == 'German' ? 1 : 2;
$this->AnrechnungbegruendungModel->addSelect(
'begruendung_id, COALESCE(bezeichnung_mehrsprachig[' . $language_index . '], bezeichnung) AS bezeichnung',
false
);
$begruendung_arr = getData($this->AnrechnungbegruendungModel->load());
$viewData = array(
@@ -94,6 +101,12 @@ class CreateAnrechnung extends Auth_Controller
{
$prestudent_id = $this->input->post('prestudent_id');
$studiensemester_kurzbz = $this->input->post('studiensemester_kurzbz');
// Exit if user has no permission for the Studiengang of the student
if (!$this->anrechnunglib->isBerechtigtForPrestudent(self::BERECHTIGUNG_ANRECHNUNG_ANLEGEN, 's', $prestudent_id))
{
$this->terminateWithJsonError($this->p->t('ui', 'keineBerechtigung'));
}
// Get Student UID
$student_uid = $this->StudentModel->getUID($prestudent_id);
@@ -136,6 +149,19 @@ class CreateAnrechnung extends Auth_Controller
{
$this->terminateWithJsonError($this->p->t('ui', 'errorFelderFehlen'));
}
// Exit if user has no write permission for the Studiengang of the student
if (!$this->anrechnunglib->isBerechtigtForPrestudent(self::BERECHTIGUNG_ANRECHNUNG_ANLEGEN, 'suid', $prestudent_id))
{
$this->terminateWithJsonError($this->p->t('ui', 'keineBerechtigung'));
}
// Exit if the Lehrveranstaltung is not one of the LVs the form offers for this student
$result = $this->LehrveranstaltungModel->getLvsByStudent($this->StudentModel->getUID($prestudent_id), $studiensemester_kurzbz);
if (!in_array($lehrveranstaltung_id, array_column(hasData($result) ? getData($result) : array(), 'lehrveranstaltung_id')))
{
$this->terminateWithJsonError($this->p->t('anrechnung', 'lvNichtZugeteilt'));
}
// Exit if application already exists
if (self::_applicationExists($lehrveranstaltung_id, $studiensemester_kurzbz, $prestudent_id))
@@ -88,6 +88,10 @@ class requestAnrechnung extends Auth_Controller
// Get Anrechung data
$anrechnungData = $this->anrechnunglib->getAnrechnungDataByLv($lehrveranstaltung_id, $studiensemester_kurzbz, $prestudent_id);
// Check if a new Antrag is for a Lehrveranstaltung outside the Studienplan (e.g. LV of a parallel study)
$is_not_in_studienplan = !is_numeric($anrechnungData->anrechnung_id)
&& !hasData($this->LehrveranstaltungModel->getLvInStudienplan($prestudent_id, $lehrveranstaltung_id));
// Get Antrag data
$antragData = $this->anrechnunglib->getAntragData($prestudent_id, $studiensemester_kurzbz, $lehrveranstaltung_id, $anrechnungData->anrechnung_id);
@@ -95,7 +99,8 @@ class requestAnrechnung extends Auth_Controller
'antragData' => $antragData,
'anrechnungData' => $anrechnungData,
'is_expired' => $is_expired,
'is_blocked' => $is_blocked
'is_blocked' => $is_blocked,
'is_not_in_studienplan' => $is_not_in_studienplan
);
$this->load->view('lehre/anrechnung/requestAnrechnung.php', $viewData);
@@ -154,7 +159,7 @@ class requestAnrechnung extends Auth_Controller
// Exit if application already exists
if (self::_applicationExists($lehrveranstaltung_id, $studiensemester_kurzbz, $prestudent_id))
{
return $this->outputJsonError($this->p->t('anrechnung', 'antragBereitsGestellt'));
return $this->outputJsonError($this->p->t('global', 'antragBereitsGestellt'));
}
// Exit if application is a past ( < actual ) studysemester
@@ -162,6 +167,30 @@ class requestAnrechnung extends Auth_Controller
{
return $this->outputJsonError($this->p->t('anrechnung', 'antragNichtFuerVerganganeSS'));
}
// Exit if student is not assigned to the Lehrveranstaltung
if (!hasData($this->LehrveranstaltungModel->getLvByStudent($this->_uid, $studiensemester_kurzbz, $lehrveranstaltung_id)))
{
return $this->outputJsonError($this->p->t('anrechnung', 'lvNichtZugeteilt'));
}
// Exit if Lehrveranstaltung is not in the Studienplan of the student (e.g. LV of a parallel study)
if (!hasData($this->LehrveranstaltungModel->getLvInStudienplan($prestudent_id, $lehrveranstaltung_id)))
{
return $this->outputJsonError($this->p->t('anrechnung', 'lvNichtImStudienplan'));
}
// Exit if application deadline is expired
if ($this->_isExpired($studiensemester_kurzbz))
{
return $this->outputJsonError($this->p->t('anrechnung', 'deadlineUeberschritten'));
}
// Exit if Lehrveranstaltung was already graded with application blocking grades
if (self::_LVhasBlockingGrades($studiensemester_kurzbz, $lehrveranstaltung_id))
{
return $this->outputJsonError($this->p->t('anrechnung', 'antragBenotungBlockiert'));
}
// Upload document
$result = self::_uploadFile();
@@ -116,7 +116,7 @@ class reviewAnrechnungDetail extends Auth_Controller
if(isEmptyArray($data))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
// Get lectors person data
@@ -173,7 +173,7 @@ class reviewAnrechnungDetail extends Auth_Controller
if(isEmptyArray($data))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
// Get lectors person data
@@ -91,7 +91,7 @@ class reviewAnrechnungUebersicht extends Auth_Controller
if(isEmptyArray($data))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
foreach ($data as $item)
@@ -140,7 +140,7 @@ class reviewAnrechnungUebersicht extends Auth_Controller
if(isEmptyArray($data))
{
return $this->outputJsonError('Fehler beim Übertragen der Daten.');
return $this->outputJsonError($this->p->t('anrechnung', 'fehlerDatenuebertragung'));
}
foreach ($data as $item)
+126 -16
View File
@@ -14,6 +14,8 @@ class AnrechnungLib
const ANRECHNUNG_NOTIZTITEL_NOTIZ_BY_STGL = 'AnrechnungNotizSTGL';
const ANRECHNUNG_NOTIZTITEL_EMPFEHLUNGSNOTIZ_BY_STGL = 'AnrechnungEmpfehlungsnotizSTGL';
const BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN = 'lehre/anrechnung_genehmigen';
public function __construct()
{
$this->ci =& get_instance();
@@ -57,7 +59,7 @@ class AnrechnungLib
// Load LV, but check if student is assigned to that LV. Break, if not.
if(!$lv = getData($this->ci->LehrveranstaltungModel->getLvByStudent($uid, $studiensemester_kurzbz, $lv_id))[0])
{
show_error('You are not assigned to this course yet.');
show_error($this->ci->p->t('anrechnung', 'lvNichtZugeteilt'));
}
}
@@ -73,8 +75,14 @@ class AnrechnungLib
show_error(getError($student));
}
// Studiengang of the prestudent, not of the LV (same source as the permission checks)
if (!$prestudent = getData($this->ci->PrestudentModel->load($prestudent_id))[0])
{
show_error('Failed loading prestudent data.');
}
// Get studiengang bezeichnung
if (!$studiengang = getData($this->ci->StudiengangModel->load($lv->studiengang_kz))[0])
if (!$studiengang = getData($this->ci->StudiengangModel->load($prestudent->studiengang_kz))[0])
{
show_error('Failed loading studiengang data.');
}
@@ -93,15 +101,20 @@ class AnrechnungLib
$latest_zgv_bezeichnung = hasData($result) ? getData($result)[0]->bezeichnung : '';
// Get Sum of berufliche and schulische ECTS
$result = $this->ci->LehrveranstaltungModel->getEctsSumSchulisch($uid, $prestudent_id, $lv->studiengang_kz);
$result = $this->ci->LehrveranstaltungModel->getEctsSumSchulisch($uid, $prestudent_id, $prestudent->studiengang_kz);
$sumEctsSchulisch = getData($result)[0]->ectssumschulisch;
$result = $this->ci->LehrveranstaltungModel->getEctsSumBeruflich($uid);
$sumEctsBeruflich = getData($result)[0]->ectssumberuflich;
// English names only if the LV or Studiengang has one
$english = getUserLanguage() != 'German';
// Set the given studiensemester
$antrag_data->lv_id = $lv_id;
$antrag_data->lv_bezeichnung = $lv->bezeichnung;
$antrag_data->lv_bezeichnung = $english && !isEmptyString($lv->bezeichnung_english)
? $lv->bezeichnung_english
: $lv->bezeichnung;
$antrag_data->ects = $lv->ects;
$antrag_data->sumEctsSchulisch = $sumEctsSchulisch;
$antrag_data->sumEctsBeruflich = $sumEctsBeruflich;
@@ -111,7 +124,9 @@ class AnrechnungLib
$antrag_data->student_uid = $uid;
$antrag_data->matrikelnr = $student->matrikelnr;
$antrag_data->studiengang_kz = $studiengang->studiengang_kz;
$antrag_data->stg_bezeichnung = $studiengang->bezeichnung;
$antrag_data->stg_bezeichnung = $english && !isEmptyString($studiengang->english)
? $studiengang->english
: $studiengang->bezeichnung;
$antrag_data->lektoren = $lv_lektoren_arr;
$antrag_data->zgv = $latest_zgv_bezeichnung;
@@ -458,6 +473,12 @@ class AnrechnungLib
*/
public function approveAnrechnung($anrechnung_id)
{
// Exit if user has no write permission for the Studiengang of the Anrechnung
if (!$this->isBerechtigtForAnrechnung(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 'suid', $anrechnung_id))
{
return false;
}
// Check last Anrechnungstatus
if (!$result = getData($this->ci->AnrechnungModel->getLastAnrechnungstatus($anrechnung_id))[0])
{
@@ -507,6 +528,12 @@ class AnrechnungLib
*/
public function rejectAnrechnung($anrechnung_id, $begruendung)
{
// Exit if user has no write permission for the Studiengang of the Anrechnung
if (!$this->isBerechtigtForAnrechnung(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 'suid', $anrechnung_id))
{
return false;
}
// Check last Anrechnungstatus
if (!$result = getData($this->ci->AnrechnungModel->getLastAnrechnungstatus($anrechnung_id))[0])
{
@@ -555,6 +582,12 @@ class AnrechnungLib
*/
public function requestRecommendation($anrechnung_id)
{
// Exit if user has no write permission for the Studiengang of the Anrechnung
if (!$this->isBerechtigtForAnrechnung(self::BERECHTIGUNG_ANRECHNUNG_GENEHMIGEN, 'suid', $anrechnung_id))
{
return false;
}
// Check last Anrechnungstatus
if (!$result = getData($this->ci->AnrechnungModel->getLastAnrechnungstatus($anrechnung_id))[0])
{
@@ -575,7 +608,7 @@ class AnrechnungLib
$this->ci->db->trans_start(false);
// Insert new status inProgressLektor
$result = $this->ci->AnrechnungModel->saveAnrechnungstatus($anrechnung_id, self::ANRECHNUNGSTATUS_PROGRESSED_BY_LEKTOR);
$this->ci->AnrechnungModel->saveAnrechnungstatus($anrechnung_id, self::ANRECHNUNGSTATUS_PROGRESSED_BY_LEKTOR);
/**
* Anyway update empfehlung_anrechnung to be null
@@ -595,7 +628,7 @@ class AnrechnungLib
if ($this->ci->db->trans_status() === false)
{
$this->ci->db->trans_rollback();
return error($result->msg, EXIT_ERROR);
return false;
}
return true; // recommended
@@ -609,6 +642,12 @@ class AnrechnungLib
*/
public function recommendAnrechnung($anrechnung_id)
{
// Exit if user did not receive the request for recommendation
if (!$this->isEmpfehlungsberechtigt($anrechnung_id))
{
return false;
}
// Check last Anrechnungstatus
if (!$result = getData($this->ci->AnrechnungModel->getLastAnrechnungstatus($anrechnung_id))[0])
{
@@ -620,7 +659,7 @@ class AnrechnungLib
// Exit if already approved or rejected
if ($status_kurzbz == self::ANRECHNUNGSTATUS_APPROVED || $status_kurzbz == self::ANRECHNUNGSTATUS_REJECTED)
{
return success(false); // dont approve
return false; // dont approve
}
// Start DB transaction
@@ -657,6 +696,12 @@ class AnrechnungLib
*/
public function dontRecommendAnrechnung($anrechnung_id, $begruendung)
{
// Exit if user did not receive the request for recommendation
if (!$this->isEmpfehlungsberechtigt($anrechnung_id))
{
return false;
}
// Check last Anrechnungstatus
if (!$result = getData($this->ci->AnrechnungModel->getLastAnrechnungstatus($anrechnung_id))[0])
{
@@ -749,7 +794,7 @@ class AnrechnungLib
$result = $this->ci->AnrechnungModel->load($anrechnung_id);
if (!hasData($result))
{
showError('Anrechnung existiert nicht');
show_error('Failed loading Anrechnung');
}
// Get lectors of lehrveranstaltung
@@ -770,16 +815,72 @@ class AnrechnungLib
*/
public function isEmpfehlungsberechtigt($anrechnung_id)
{
if($this->ci->config->item('fbl') === TRUE)
// load() without an ID loads all Anrechnungen
if (!is_numeric($anrechnung_id))
{
return true;
return false;
}
// Get lv-leitungen or, if not present, all lectors of lv.
$lector_arr = $this->getLectors($anrechnung_id);
// Return false if lv-leitung is present and user is not lv-leitung. Otherways return always true.
return in_array(getAuthUID(), array_column($lector_arr, 'uid'));
// Get Leitungen of the LV-OE (fbl), or lv-leitungen or, if not present, all lectors of lv.
$receiver_arr = $this->ci->config->item('fbl') === TRUE
? $this->getLeitungOfLvOe($anrechnung_id)
: $this->getLectors($anrechnung_id);
// True if user received the request for recommendation
return is_array($receiver_arr) && in_array(getAuthUID(), array_column($receiver_arr, 'uid'));
}
/**
* Check the permission for the Studiengang of the Anrechnung.
* The Studiengang of an Anrechnung is the Studiengang of its prestudent, not of the LV.
*
* @param $berechtigung_kurzbz
* @param $art
* @param $anrechnung_id
* @return bool
*/
public function isBerechtigtForAnrechnung($berechtigung_kurzbz, $art, $anrechnung_id)
{
// load() without an ID loads all Anrechnungen
if (!is_numeric($anrechnung_id))
{
return false;
}
$result = $this->ci->AnrechnungModel->load($anrechnung_id);
return hasData($result)
&& $this->isBerechtigtForPrestudent($berechtigung_kurzbz, $art, getData($result)[0]->prestudent_id);
}
/**
* Check the permission for the Studiengang of the prestudent.
*
* @param $berechtigung_kurzbz
* @param $art
* @param $prestudent_id
* @return bool
*/
public function isBerechtigtForPrestudent($berechtigung_kurzbz, $art, $prestudent_id)
{
// load() without an ID loads all prestudents
if (!is_numeric($prestudent_id))
{
return false;
}
$result = $this->ci->PrestudentModel->load($prestudent_id);
// Without a Studiengang, isBerechtigt checks the permission for any OE
if (!hasData($result))
{
return false;
}
$this->ci->load->library('PermissionLib');
return $this->ci->permissionlib->isBerechtigt($berechtigung_kurzbz, $art, getData($result)[0]->studiengang_kz);
}
/**
* Get LV Leitung. If not present, get all LV lectors.
@@ -856,6 +957,11 @@ class AnrechnungLib
$this->ci->AnrechnungModel->addSelect('lehrveranstaltung_id');
$result = $this->ci->AnrechnungModel->load($anrechnung_id);
if (!hasData($result))
{
return false;
}
$lehrveranstaltung_id = getData($result)[0]->lehrveranstaltung_id;
// Get Leitungen
@@ -889,7 +995,11 @@ class AnrechnungLib
$anrechnung_data->prestudent_id = $anrechnung->prestudent_id;
$anrechnung_data->lehrveranstaltung_id = $anrechnung->lehrveranstaltung_id;
$anrechnung_data->begruendung_id = $anrechnung->begruendung_id;
$anrechnung_data->begruendung = $anrechnung->bezeichnung;
// Begruendung in the users language, German name if no translation exists
$language_index = getUserLanguage() == 'German' ? 0 : 1;
$anrechnung_data->begruendung = !empty($anrechnung->bezeichnung_mehrsprachig[$language_index])
? $anrechnung->bezeichnung_mehrsprachig[$language_index]
: $anrechnung->bezeichnung;
$anrechnung_data->anmerkung = $anrechnung->anmerkung_student;
$anrechnung_data->dms_id = $anrechnung->dms_id;
$anrechnung_data->insertamum = (new DateTime($anrechnung->insertamum))->format('d.m.Y');
-563
View File
@@ -1,563 +0,0 @@
<?php
if (!defined('BASEPATH'))
exit('No direct script access allowed');
class CmsAdminLib
{
private $ci;
public function __construct()
{
$this->ci =& get_instance();
$this->ci->load->model('content/Content_model', 'ContentModel');
$this->ci->load->model('content/Contentsprache_model', 'ContentspracheModel');
$this->ci->load->model('content/Contentchild_model', 'ContentchildModel');
$this->ci->load->model('content/Contentgruppe_model', 'ContentgruppeModel');
$this->ci->load->model('content/Contentlog_model', 'ContentlogModel');
$this->ci->load->model('content/Template_model', 'TemplateModel');
$this->ci->load->library('XsdSchemaLib');
$this->ci->load->library('PermissionLib');
$this->ci->config->load('cms');
}
/**
* @return object success(array) entitled oe_kurzbz values
*/
public function getEntitledOe()
{
$oe = $this->ci->permissionlib->getOE_isEntitledFor('basis/cms');
if ($oe === false || !is_array($oe))
return success([]);
return success($oe);
}
/**
* @param int $content_id
* @return object success(bool)
*/
public function isEntitledForContent($content_id)
{
$oeResult = $this->ci->ContentModel->getOeKurzbz($content_id);
if (isError($oeResult))
return $oeResult;
$entitledResult = $this->getEntitledOe();
if (isError($entitledResult))
return $entitledResult;
return success(in_array(getData($oeResult), getData($entitledResult)));
}
/**
* @return object success(string) or error
*/
public function getDefaultOe()
{
$default = $this->ci->config->item('default_oe_kurzbz');
$entitledResult = $this->getEntitledOe();
if (isError($entitledResult))
return $entitledResult;
$entitled = getData($entitledResult);
if (empty($entitled))
return error($this->ci->p->t('cms', 'keineBerechtigteOe'));
if (in_array($default, $entitled))
return success($default);
return success($entitled[0]);
}
/**
* @param int|null $parent_content_id
* @return object success(array) with content_id, sprache, version
*/
public function createContent($parent_content_id = null)
{
$templatesResult = $this->ci->TemplateModel->loadWhere([]);
if (isError($templatesResult))
return $templatesResult;
$templates = getData($templatesResult);
if (empty($templates))
return error($this->ci->p->t('cms', 'keineVorlageVorhanden'));
$template = $templates[0];
$oeResult = $this->getDefaultOe();
if (isError($oeResult))
return $oeResult;
$oe = getData($oeResult);
$titel = $this->ci->p->t('cms', 'neuerEintrag');
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$this->ci->db->trans_start();
$contentResult = $this->ci->ContentModel->insert([
'template_kurzbz' => $template->template_kurzbz,
'oe_kurzbz' => $oe,
'aktiv' => true,
'menu_open' => false,
'beschreibung' => '',
'insertamum' => $now,
'insertvon' => $uid
]);
if (isError($contentResult))
return $contentResult;
$content_id = getData($contentResult);
$spracheResult = $this->ci->ContentspracheModel->insert([
'content_id' => $content_id,
'sprache' => DEFAULT_LANGUAGE,
'version' => 1,
'sichtbar' => true,
'titel' => $titel,
'content' => '<?xml version="1.0" encoding="UTF-8" ?><content></content>',
'insertamum' => $now,
'insertvon' => $uid
]);
if (isError($spracheResult))
return $spracheResult;
if ($parent_content_id !== null)
{
$maxSortResult = $this->ci->ContentchildModel->getMaxSort($parent_content_id);
if (isError($maxSortResult))
return $maxSortResult;
$childResult = $this->ci->ContentchildModel->insert([
'content_id' => $parent_content_id,
'child_content_id' => $content_id,
'sort' => getData($maxSortResult) + 1,
'insertamum' => $now,
'insertvon' => $uid
]);
if (isError($childResult))
return $childResult;
}
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success([
'content_id' => $content_id,
'sprache' => DEFAULT_LANGUAGE,
'version' => 1
]);
}
/**
* @param int $content_id
* @param string $sprache source language
* @param int $version source version
* @param string $neueSprache target language
* @return object success(array) with contentsprache_id
*/
public function createTranslation($content_id, $sprache, $version, $neueSprache)
{
$existsResult = $this->ci->ContentspracheModel->exists($content_id, $neueSprache);
if (isError($existsResult))
return $existsResult;
if (getData($existsResult))
return error($this->ci->p->t('cms', 'uebersetzungExistiert'));
$sourceResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($sourceResult))
return $sourceResult;
$source = getData($sourceResult);
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$insertResult = $this->ci->ContentspracheModel->insert([
'content_id' => $content_id,
'sprache' => $neueSprache,
'version' => $source->version,
'sichtbar' => true,
'titel' => $source->titel,
'content' => $source->content,
// The review feature is gone from this admin, but the columns stay
'reviewvon' => null,
'reviewamum' => null,
'gesperrt_uid' => null,
'insertamum' => $now,
'insertvon' => $uid,
'updateamum' => $now,
'updatevon' => $uid
]);
if (isError($insertResult))
return $insertResult;
return success(['contentsprache_id' => getData($insertResult)]);
}
/**
* @param int $content_id
* @param string $sprache
* @return object success(array) with version
*/
public function createVersion($content_id, $sprache)
{
$sourceResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, null);
if (isError($sourceResult))
return $sourceResult;
$source = getData($sourceResult);
$maxResult = $this->ci->ContentspracheModel->getMaxVersion($content_id, $sprache);
if (isError($maxResult))
return $maxResult;
$newVersion = getData($maxResult) + 1;
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$insertResult = $this->ci->ContentspracheModel->insert([
'content_id' => $content_id,
'sprache' => $sprache,
'version' => $newVersion,
'sichtbar' => false,
'titel' => $source->titel,
'content' => $source->content,
// The review feature is gone from this admin, but the columns stay
'reviewvon' => null,
'reviewamum' => null,
'gesperrt_uid' => null,
'insertamum' => $now,
'insertvon' => $uid,
'updateamum' => $now,
'updatevon' => $uid
]);
if (isError($insertResult))
return $insertResult;
return success(['version' => $newVersion]);
}
/**
* Writes the properties of one content version.
* @param array $daten content_id, sprache, version, template_kurzbz, oe_kurzbz,
* aktiv, menu_open, beschreibung, titel, sichtbar
* @return object success(true) or error
*/
public function saveProperties($daten)
{
$versionResult = $this->ci->ContentspracheModel->getOne(
$daten['content_id'], $daten['sprache'], $daten['version']
);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$now = date('Y-m-d H:i:s');
$uid = getAuthUID();
$this->ci->db->trans_start();
$this->ci->ContentModel->update($daten['content_id'], [
'template_kurzbz' => $daten['template_kurzbz'],
'oe_kurzbz' => $daten['oe_kurzbz'],
'aktiv' => $daten['aktiv'],
'menu_open' => $daten['menu_open'],
'beschreibung' => $daten['beschreibung'],
'updateamum' => $now,
'updatevon' => $uid
]);
$this->ci->ContentspracheModel->update($row->contentsprache_id, [
'titel' => $daten['titel'],
'sichtbar' => $daten['sichtbar'],
'updateamum' => $now,
'updatevon' => $uid
]);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success(true);
}
// legacy cms checks neither the permission
// type nor the lock. The form appears only when the user holds the lock, but the POST
// itself is unprotected.
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @param array $values field name => value
* @return object success(true) or error
*/
public function saveContentXml($content_id, $sprache, $version, $values)
{
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$contentResult = $this->ci->ContentModel->load($content_id);
if (isError($contentResult))
return $contentResult;
$content = getData($contentResult);
if (empty($content))
return error($this->ci->p->t('cms', 'contentNichtGefunden'));
$templateResult = $this->ci->TemplateModel->load($content[0]->template_kurzbz);
if (isError($templateResult))
return $templateResult;
$template = getData($templateResult);
if (empty($template))
return error($this->ci->p->t('cms', 'keineVorlageVorhanden'));
$schemaResult = $this->ci->xsdschemalib->parseSchema(
$template[0]->xsd, $content[0]->template_kurzbz
);
if (isError($schemaResult))
return $schemaResult;
$xmlResult = $this->ci->xsdschemalib->buildXml(getData($schemaResult), $values);
if (isError($xmlResult))
return $xmlResult;
$updateResult = $this->ci->ContentspracheModel->update(
$row->contentsprache_id,
['content' => getData($xmlResult)]
);
if (isError($updateResult))
return $updateResult;
return success(true);
}
/**
* Locks a version for the current user, or takes over an expired lock.
* @param int $contentsprache_id
* @return object success(true) or error
*/
public function lock($contentsprache_id)
{
$uid = getAuthUID();
$rowResult = $this->ci->ContentspracheModel->load($contentsprache_id);
if (isError($rowResult))
return $rowResult;
$rowData = getData($rowResult);
if (empty($rowData))
return error($this->ci->p->t('cms', 'versionNichtGefunden'));
$holder = ($rowData[0]->gesperrt_uid === '') ? null : $rowData[0]->gesperrt_uid;
if ($holder !== null && $holder !== $uid)
{
$openResult = $this->ci->ContentlogModel->getOpenLock($contentsprache_id, $holder);
$openEntry = (!isError($openResult)) ? getData($openResult) : null;
if (!$this->isLockExpired($holder, $openEntry))
return error($this->ci->p->t('cms', 'bereitsGesperrt'));
}
$this->ci->db->trans_start();
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id);
$logResult = $this->ci->ContentlogModel->insert([
'uid' => $uid,
'contentsprache_id' => $contentsprache_id,
'start' => date('Y-m-d H:i:s')
]);
if (isError($logResult))
return $logResult;
$updateResult = $this->ci->ContentspracheModel->update($contentsprache_id, [
'gesperrt_uid' => $uid
]);
if (isError($updateResult))
return $updateResult;
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success(true);
}
// legacy released ALL locks of the user. Releasing now ends one lock.
// The uid predicate stops a user releasing a lock of somebody else.
/**
* Releases the lock of the current user on one version.
* @param int $contentsprache_id
* @return object success(true)
*/
public function releaseOwnLock($contentsprache_id)
{
$uid = getAuthUID();
$this->ci->db->trans_start();
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id, $uid);
$this->ci->ContentspracheModel->releaseLock($contentsprache_id, $uid);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success(true);
}
/**
* @param int $contentsprache_id
* @return object success(true)
*/
public function forceRelease($contentsprache_id)
{
$this->ci->db->trans_start();
$this->ci->ContentlogModel->closeOpenEntries($contentsprache_id);
$this->ci->ContentspracheModel->releaseLock($contentsprache_id);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'speichernFehlgeschlagen'));
return success(true);
}
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @return object success(array) gesperrt_uid, start, expires, own, expired, may_force
*/
public function getLockState($content_id, $sprache, $version)
{
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
// legacy admin.php inserts an empty string, releasing writes NULL.
// this code reports NULL.
$gesperrt_uid = ($row->gesperrt_uid === '') ? null : $row->gesperrt_uid;
$logEntry = null;
if ($gesperrt_uid !== null)
{
$lockResult = $this->ci->ContentlogModel->getOpenLock(
$row->contentsprache_id, $gesperrt_uid
);
$logEntry = (!isError($lockResult)) ? getData($lockResult) : null;
}
return success([
'contentsprache_id' => (int) $row->contentsprache_id,
'gesperrt_uid' => $gesperrt_uid,
'start' => $logEntry ? $logEntry->start : null,
'own' => ($gesperrt_uid !== null && $gesperrt_uid === getAuthUID()),
'expired' => $this->isLockExpired($gesperrt_uid, $logEntry),
'expires' => $this->lockExpiryDate($logEntry),
'may_force' => $this->ci->permissionlib->isBerechtigt('basis/cms_sperrfreigabe', 'su')
]);
}
/**
* End of the lock window, null if the entry carries no start.
* @param stdClass|null $logEntry
* @return string|null
*/
private function lockExpiryDate($logEntry)
{
if ($logEntry === null || empty($logEntry->start))
return null;
$ttlHours = (int) $this->ci->config->item('lock_ttl_hours');
// lock() writes start with PHP time, so stay on PHP time.
return date('Y-m-d H:i:s', strtotime($logEntry->start) + $ttlHours * 3600);
}
// Locks now age out after lock_ttl_hours.
/**
* A lock without a log entry has no age and counts as expired.
* @param string|null $gesperrt_uid
* @param stdClass|null $logEntry
* @return bool
*/
private function isLockExpired($gesperrt_uid, $logEntry)
{
if ($gesperrt_uid === null)
return false;
$expires = $this->lockExpiryDate($logEntry);
return $expires === null || strtotime($expires) < time();
}
/**
* @param int $content_id
* @return object success(true) or error
*/
public function deleteContent($content_id)
{
$this->ci->db->trans_start();
$this->ci->ContentchildModel->deleteByContent($content_id);
$this->ci->ContentlogModel->deleteByContent($content_id);
$this->ci->ContentspracheModel->deleteByContent($content_id);
$this->ci->ContentgruppeModel->deleteByContent($content_id);
$this->ci->ContentModel->delete($content_id);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'loeschenNichtMoeglichReferenzen'));
return success(true);
}
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @return object success(true) or error
*/
public function deleteVersion($content_id, $sprache, $version)
{
if ($sprache === DEFAULT_LANGUAGE)
{
$countResult = $this->ci->ContentspracheModel->getNumberOfVersions($content_id, $sprache);
if (isError($countResult))
return $countResult;
if (getData($countResult) === 1)
return error($this->ci->p->t('cms', 'letzteVersionNichtLoeschbar'));
}
$versionResult = $this->ci->ContentspracheModel->getOne($content_id, $sprache, $version);
if (isError($versionResult))
return $versionResult;
$row = getData($versionResult);
$this->ci->db->trans_start();
$this->ci->ContentlogModel->deleteByContentsprache($row->contentsprache_id);
$this->ci->ContentspracheModel->deleteVersion($content_id, $sprache, $version);
$this->ci->db->trans_complete();
if ($this->ci->db->trans_status() === false)
return error($this->ci->p->t('cms', 'loeschenFehlgeschlagen'));
return success(true);
}
}
+2 -4
View File
@@ -42,12 +42,10 @@ class CmsLib
* @param int $version
* @param string $sprache
* @param boolean $sichtbar
* @param boolean $log false suppresses the click log entry. The CMS admin
* preview renders a content without counting a view.
*
* @return stdClass
*/
public function getContent($content_id, $version = null, $sprache = null, $sichtbar = true, $log = true)
public function getContent($content_id, $version = null, $sprache = null, $sichtbar = true)
{
if (!is_numeric($content_id))
return error('ContentID ist ungueltig');
@@ -74,7 +72,7 @@ class CmsLib
return $content;
// Legt einen Logeintrag für die Klickstatistik an
if ($log && defined('LOG_CONTENT') && LOG_CONTENT) {
if (defined('LOG_CONTENT') && LOG_CONTENT) {
// Nur eingeloggte User werden geloggt, das sonst auch alle Infoscreenaufrufe und dgl. mitgeloggt werden
if (isLogged()) {
$request_data = 'content_id=' . $content_id;
+2 -17
View File
@@ -7,7 +7,6 @@ use \DateTime as DateTime;
use \DateTimeZone as DateTimeZone;
use \DateInterval as DateInterval;
use \DatePeriod as DatePeriod;
use CI3_Events as Events;
class StundenplanLib
{
@@ -144,7 +143,7 @@ class StundenplanLib
return $stundenplan_data;
$stundenplan_data = getData($stundenplan_data) ?? [];
$function_error = $this->expandObjectInformation($stundenplan_data, true);
$function_error = $this->expandObjectInformation($stundenplan_data);
if ($function_error)
return $function_error;
@@ -304,7 +303,7 @@ class StundenplanLib
return success($lektoren);
}
public function expandObjectInformation($data, $callExtensionsEvent = false)
public function expandObjectInformation($data)
{
$this->_ci =& get_instance();
@@ -393,20 +392,6 @@ class StundenplanLib
$item->lektor = $lektor_obj_array;
}
// info retrieved from extensions might not be necessary for some stundenplan use cases so the event
// will be gated behind this flag
if($callExtensionsEvent) {
Events::trigger(
'extendStundenplanData',
function & () use (&$data)
{
return $data;
}
);
}
}
public function fetchFerienTageEvents($start_date, $end_date, $studiengang_kz)
-195
View File
@@ -1,195 +0,0 @@
<?php
if (!defined('BASEPATH'))
exit('No direct script access allowed');
use \DOMDocument as DOMDocument;
class XsdSchemaLib
{
private $ci;
public function __construct()
{
$this->ci =& get_instance();
}
/**
* Converts an XSD into a field schema.
* @param string $xsd the XSD as a string
* @param string $templateKurzbz short name of the template
* @return object success(array) with template_kurzbz, root, fields
*/
public function parseSchema($xsd, $templateKurzbz)
{
libxml_use_internal_errors(true);
$dom = new DOMDocument();
if (!$dom->loadXML($xsd))
{
libxml_clear_errors();
return error($this->ci->p->t('cms', 'xsdUngueltig'));
}
libxml_clear_errors();
$root = null;
$fields = array();
$this->_walkElements($dom->documentElement, $root, $fields);
return success(array(
'template_kurzbz' => $templateKurzbz,
'root' => $root,
'fields' => $fields
));
}
/**
* Reads the values of a content from the XML.
* @param string $xml the value of tbl_contentsprache.content
* @param array $schema the result of parseSchema
* @return object success(array) field name => value
*/
public function extractValues($xml, $schema)
{
$values = array();
foreach ($schema['fields'] as $field)
{
$values[$field['name']] = '';
}
if ($xml === null || trim($xml) === '')
{
return success($values);
}
libxml_use_internal_errors(true);
$dom = new DOMDocument();
if (!$dom->loadXML($xml))
{
libxml_clear_errors();
return success($values);
}
libxml_clear_errors();
foreach ($schema['fields'] as $field)
{
$nodes = $dom->getElementsByTagName($field['name']);
if ($nodes->length > 0)
{
$values[$field['name']] = $nodes->item(0)->nodeValue;
}
}
return success($values);
}
/**
* Builds the XML from the values.
* @param array $schema the result of parseSchema
* @param array $values field name => value
* @return object success(string) the XML
*/
public function buildXml($schema, $values)
{
$xml = '<?xml version="1.0" encoding="UTF-8"?>';
$xml .= "\n<" . $schema['root'] . '>';
foreach ($schema['fields'] as $field)
{
$value = isset($values[$field['name']]) ? $values[$field['name']] : '';
$xml .= "\n<" . $field['name'] . '><![CDATA[' . $this->_escapeCdata($value) . ']]></' . $field['name'] . '>';
}
$xml .= "\n</" . $schema['root'] . '>';
libxml_use_internal_errors(true);
$check = new DOMDocument();
if (!$check->loadXML($xml))
{
libxml_clear_errors();
return error($this->ci->p->t('cms', 'xmlUngueltig'));
}
libxml_clear_errors();
return success($xml);
}
// -------------------------------------------------------------------------
// Private methods
/**
* Walks xs:element nodes recursively.
* The first element without a type attribute is the root.
* Elements with a type attribute are fields.
* xs:complexType and xs:sequence are traversed and ignored.
*/
private function _walkElements($node, &$root, &$fields)
{
if ($node->nodeType !== XML_ELEMENT_NODE)
{
return;
}
$localName = $node->localName;
if ($localName === 'element')
{
$typeAttr = $node->getAttribute('type');
if ($typeAttr === '')
{
if ($root === null)
{
$root = $node->getAttribute('name');
}
}
else
{
$minOccurs = $node->getAttribute('minOccurs');
$required = ($minOccurs === '') ? true : ((int)$minOccurs > 0);
$fields[] = array(
'name' => $node->getAttribute('name'),
'type' => $this->_mapType($typeAttr),
'required' => $required
);
}
}
foreach ($node->childNodes as $child)
{
$this->_walkElements($child, $root, $fields);
}
}
/**
* Maps an XSD type to the field schema type.
*/
private function _mapType($xsdType)
{
$map = array(
'xs:string' => 'string',
'xs:decimal' => 'string',
'xs:integer' => 'string',
'xs:positiveInteger' => 'string',
'xs:date' => 'date',
'wysiwyg' => 'wysiwyg',
'file' => 'file',
'boolean' => 'boolean'
);
return isset($map[$xsdType]) ? $map[$xsdType] : 'string';
}
// Escapes ]]> in the value. The legacy code does not do this and writes broken XML.
private function _escapeCdata($value)
{
return str_replace(']]>', ']]]]><![CDATA[>', $value);
}
}
@@ -323,266 +323,4 @@ class Content_model extends DB_Model
return success(isset($result[$root_content_id]) ? $result[$root_content_id] : null);
}
/**
* Root contents without a parent in tbl_contentchild. Excludes news.
* @return stdClass success with array of rows or error
*/
public function getRootContent()
{
$query = '
SELECT *
FROM (
SELECT DISTINCT ON (content_id) *
FROM campus.tbl_content
LEFT JOIN campus.tbl_contentchild USING (content_id)
WHERE tbl_content.template_kurzbz <> ?
AND content_id NOT IN (
SELECT child_content_id FROM campus.tbl_contentchild
WHERE child_content_id = tbl_content.content_id)
) AS a
ORDER BY sort, content_id
';
return $this->execReadOnlyQuery($query, ['news']);
}
/**
* Recent news from the last two months, max 100 rows.
* @return stdClass success with array of rows or error
*/
public function getNewsContent()
{
$query = "
SELECT *
FROM campus.tbl_content
JOIN campus.tbl_news USING (content_id)
WHERE tbl_news.datum >= now() - '2 month'::interval
ORDER BY datum DESC
LIMIT 100
";
return $this->execReadOnlyQuery($query);
}
/**
* Search contents by content_id or titel. Excludes news. Returns content_ids only.
* @param array $searchItems search terms
* @return stdClass success with array of rows or error
*/
public function searchCms($searchItems)
{
if (empty($searchItems))
return success([]);
$conditions = [];
$params = [];
foreach ($searchItems as $term)
{
$conditions[] = '(content_id::text = ? OR lower(titel) LIKE lower(?))';
$params[] = $term;
$params[] = '%' . $term . '%';
}
$query = '
SELECT DISTINCT tbl_content.content_id
FROM campus.tbl_contentsprache
JOIN campus.tbl_content USING (content_id)
WHERE tbl_content.template_kurzbz <> ?
AND (' . implode(' OR ', $conditions) . ')
ORDER BY content_id
';
array_unshift($params, 'news');
return $this->execReadOnlyQuery($query, $params);
}
/**
* Per content metadata for the tree filter. Excludes news, like getRootContent().
* updateamum is the last touch on the content or on any of its versions.
*
* contentlength counts the visible characters of the newest version, the way getOne()
* picks it: the default language first, then the highest version. The XML scaffolding,
* the CDATA markers, the HTML tags, the entities and every space drop out, so a content
* that nobody ever filled measures 0 whatever empty shape it carries. The title counts
* too, so a content with a title and no body measures short instead of zero.
*
* @param string $uid current user, decides the mine flag
* @param string $sprache preferred language of the measured version
* @return stdClass success with array of rows or error
*/
public function getTreeMeta($uid, $sprache)
{
$query = "
SELECT tbl_content.content_id,
(SELECT COUNT(*) FROM campus.tbl_contentchild
WHERE tbl_contentchild.content_id = tbl_content.content_id) AS childcount,
tbl_content.insertamum,
GREATEST(
tbl_content.updateamum,
(SELECT MAX(updateamum) FROM campus.tbl_contentsprache
WHERE tbl_contentsprache.content_id = tbl_content.content_id)
) AS updateamum,
COALESCE(
tbl_content.insertvon = ? OR tbl_content.updatevon = ? OR EXISTS (
SELECT 1 FROM campus.tbl_contentsprache
WHERE tbl_contentsprache.content_id = tbl_content.content_id
AND (tbl_contentsprache.insertvon = ?
OR tbl_contentsprache.updatevon = ?)
),
false
) AS mine,
(SELECT length(
regexp_replace(
regexp_replace(
regexp_replace(
replace(replace(cs.content::text, '<![CDATA[', ''), ']]>', ''),
'<[^>]*>', '', 'g'),
'&[a-zA-Z]+;|&#[0-9]+;', '', 'g'),
'\s+', '', 'g')
)
FROM campus.tbl_contentsprache cs
WHERE cs.content_id = tbl_content.content_id
ORDER BY (cs.sprache = ?) DESC, cs.version DESC
LIMIT 1) AS contentlength
FROM campus.tbl_content
WHERE tbl_content.template_kurzbz <> ?
";
return $this->execReadOnlyQuery(
$query, [$uid, $uid, $uid, $uid, $sprache, 'news']
);
}
/**
* All contents eligible as children: excludes ancestors, self, and news.
* @param int $content_id the content to find children for
* @param string $sprache language for the titel subselect
* @return stdClass success with array of rows or error
*/
public function getPossibleChilds($content_id, $sprache)
{
$query = '
SELECT content_id, oe_kurzbz, template_kurzbz,
(SELECT titel FROM campus.tbl_contentsprache
WHERE sprache = ? AND content_id = tbl_content.content_id
ORDER BY version LIMIT 1) AS titel
FROM campus.tbl_content
WHERE content_id NOT IN (
WITH RECURSIVE parents(content_id, child_content_id) AS (
SELECT content_id, child_content_id FROM campus.tbl_contentchild
WHERE child_content_id = ?
UNION ALL
SELECT cc.content_id, cc.child_content_id
FROM campus.tbl_contentchild cc, parents
WHERE cc.child_content_id = parents.content_id)
SELECT content_id FROM parents GROUP BY content_id)
AND content_id <> ?
AND template_kurzbz <> ?
ORDER BY titel
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id, $content_id, 'news']);
}
/**
* Where a content is referenced. Returns [{table, label}] for the delete dialog.
* Tolerates missing addon/testtool schemas.
* @param int $content_id
* @return stdClass success with flat array or error
*/
public function getUsage($content_id)
{
$coreQuery = "
SELECT 'campus.tbl_infoscreen_content' AS \"table\",
infoscreen_id::text AS label
FROM campus.tbl_infoscreen_content WHERE content_id = ?
UNION ALL
SELECT 'campus.tbl_news', betreff
FROM campus.tbl_news WHERE content_id = ?
UNION ALL
SELECT 'public.tbl_ort', ort_kurzbz
FROM public.tbl_ort WHERE content_id = ?
UNION ALL
SELECT 'public.tbl_service', bezeichnung
FROM public.tbl_service WHERE content_id = ?
UNION ALL
SELECT 'public.tbl_statistik', bezeichnung
FROM public.tbl_statistik WHERE content_id = ?
";
$coreParams = [$content_id, $content_id, $content_id, $content_id, $content_id];
$coreResult = $this->execReadOnlyQuery($coreQuery, $coreParams);
$rows = [];
if (!isError($coreResult) && getData($coreResult))
$rows = getData($coreResult);
// Optional schemas: testtool and addon may not exist. A failing query still writes a
// db error into the response envelope, so check for the table before querying it.
$optionalQueries = [
[
'testtool', 'tbl_ablauf_vorgaben',
"SELECT 'testtool.tbl_ablauf_vorgaben' AS \"table\",
ablauf_vorgaben_id::text AS label
FROM testtool.tbl_ablauf_vorgaben WHERE content_id = ?"
],
[
'addon', 'tbl_software',
"SELECT 'addon.tbl_software' AS \"table\",
software_id::text AS label
FROM addon.tbl_software WHERE content_id = ?"
]
];
foreach ($optionalQueries as $opt)
{
if (!$this->tableExists($opt[0], $opt[1]))
continue;
$result = $this->execReadOnlyQuery($opt[2], [$content_id]);
if (!isError($result) && getData($result))
$rows = array_merge($rows, getData($result));
}
return success($rows);
}
/**
* True if a base table exists. Guards the optional testtool and addon schemas.
* @param string $schema
* @param string $table
* @return bool
*/
private function tableExists($schema, $table)
{
$query = "
SELECT 1 AS exists
FROM information_schema.tables
WHERE table_catalog = ? AND table_schema = ? AND table_name = ?
";
return hasData($this->execReadOnlyQuery($query, [DB_NAME, $schema, $table]));
}
/**
* Returns oe_kurzbz for a content as a string.
* @param int $content_id
* @return stdClass success with string or error
*/
public function getOeKurzbz($content_id)
{
$query = 'SELECT oe_kurzbz FROM campus.tbl_content WHERE content_id = ?';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
$data = getData($result);
if (empty($data))
return error('Content not found');
return success($data[0]->oe_kurzbz);
}
}
@@ -1,9 +1,6 @@
<?php
class Contentchild_model extends DB_Model
{
// swapSort reports the two boundary cases with this. See swapSort().
const NO_NEIGHBOUR = 'no neighbour';
/**
* Constructor
@@ -14,345 +11,4 @@ class Contentchild_model extends DB_Model
$this->dbTable = 'campus.tbl_contentchild';
$this->pk = 'contentchild_id';
}
/**
* Children with titel, sorted by sort.
* @param int $content_id
* @param string $sprache
* @return stdClass success with array of rows or error
*/
public function getChilds($content_id, $sprache)
{
$query = '
SELECT cc.contentchild_id, cc.child_content_id, cc.sort,
(SELECT titel FROM campus.tbl_contentsprache
WHERE content_id = cc.child_content_id AND sprache = ?
ORDER BY version LIMIT 1) AS titel
FROM campus.tbl_contentchild cc
WHERE cc.content_id = ?
ORDER BY cc.sort, cc.contentchild_id
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id]);
}
/**
* Parents of a content, with the title of each parent.
* A content can hang under more than one parent, so this returns a list.
* @param int $content_id
* @param string $sprache
* @return stdClass success with array of rows or error
*/
public function getParents($content_id, $sprache)
{
$query = '
SELECT cc.contentchild_id, cc.content_id, cc.sort,
(SELECT titel FROM campus.tbl_contentsprache
WHERE content_id = cc.content_id AND sprache = ?
ORDER BY version LIMIT 1) AS titel
FROM campus.tbl_contentchild cc
WHERE cc.child_content_id = ?
ORDER BY cc.content_id
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id]);
}
/**
* Children of a content as a reader may see them.
*
* Differs from getChilds() on purpose. getChilds() serves the admin and returns every
* child with the lowest version. A reader needs the newest visible version, only active
* contents, and no page that a group rule keeps from them.
*
* The group rule follows CmsLib::getContent(): a content without a row in
* tbl_contentgruppe is open, a content with rows needs membership in one of them.
*
* @param int $content_id
* @param string $sprache
* @param string $uid
* @return stdClass success with array of rows or error
*/
public function getChildsForReader($content_id, $sprache, $uid)
{
$query = '
SELECT
cc.child_content_id AS content_id,
cc.sort,
COALESCE(
(SELECT cs.titel FROM campus.tbl_contentsprache cs
WHERE cs.content_id = cc.child_content_id
AND cs.sprache = ?
AND cs.sichtbar
AND LENGTH(cs.titel) > 0
ORDER BY cs.version DESC NULLS LAST LIMIT 1),
(SELECT cs.titel FROM campus.tbl_contentsprache cs
WHERE cs.content_id = cc.child_content_id
AND cs.sichtbar
AND LENGTH(cs.titel) > 0
ORDER BY cs.version DESC NULLS LAST LIMIT 1),
c.beschreibung
) AS titel
FROM campus.tbl_contentchild cc
JOIN campus.tbl_content c ON c.content_id = cc.child_content_id
WHERE cc.content_id = ?
AND c.aktiv
-- Without a visible version the reader cannot open the page at all.
AND EXISTS (
SELECT 1 FROM campus.tbl_contentsprache cs
WHERE cs.content_id = cc.child_content_id AND cs.sichtbar
)
AND (
NOT EXISTS (
SELECT 1 FROM campus.tbl_contentgruppe cg
WHERE cg.content_id = cc.child_content_id
)
OR EXISTS (
SELECT 1 FROM campus.tbl_contentgruppe cg
JOIN public.vw_gruppen vg ON vg.gruppe_kurzbz = cg.gruppe_kurzbz
WHERE cg.content_id = cc.child_content_id AND vg.uid = ?
)
)
ORDER BY cc.sort, cc.contentchild_id
';
return $this->execReadOnlyQuery($query, [$sprache, $content_id, $uid]);
}
/**
* Highest sort value. Returns 0 if no row exists.
* @param int $content_id
* @return stdClass success with int or error
*/
public function getMaxSort($content_id)
{
$query = '
SELECT COALESCE(MAX(sort), 0) AS max_sort
FROM campus.tbl_contentchild
WHERE content_id = ?
';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
return success((int) getData($result)[0]->max_sort);
}
/**
* All descendant content_ids recursively. Returns a flat array.
* @param int $content_id
* @return stdClass success with array of ints or error
*/
public function getAllChildIds($content_id)
{
$query = '
WITH RECURSIVE childs(content_id, child_content_id) AS (
SELECT content_id, child_content_id FROM campus.tbl_contentchild WHERE content_id = ?
UNION ALL
SELECT cc.content_id, cc.child_content_id
FROM campus.tbl_contentchild cc, childs
WHERE cc.content_id = childs.child_content_id)
SELECT DISTINCT child_content_id FROM childs
';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
$ids = [];
if (getData($result))
{
foreach (getData($result) as $row)
$ids[] = (int) $row->child_content_id;
}
return success($ids);
}
/**
* Swap sort value with the neighbour. Direction is 'up' or 'down'.
* @param int $contentchild_id
* @param string $direction 'up' or 'down'
* @return stdClass success or error
*/
public function swapSort($contentchild_id, $direction)
{
$currentResult = $this->load($contentchild_id);
if (isError($currentResult))
return $currentResult;
$current = getData($currentResult)[0];
if ($direction === 'up')
{
$neighbourQuery = '
SELECT contentchild_id, sort
FROM campus.tbl_contentchild
WHERE content_id = ? AND sort < ?
ORDER BY sort DESC
LIMIT 1
';
}
else
{
$neighbourQuery = '
SELECT contentchild_id, sort
FROM campus.tbl_contentchild
WHERE content_id = ? AND sort > ?
ORDER BY sort ASC
LIMIT 1
';
}
$neighbourResult = $this->execReadOnlyQuery($neighbourQuery, [$current->content_id, $current->sort]);
if (isError($neighbourResult))
return $neighbourResult;
$neighbourData = getData($neighbourResult);
// A marker, not a message. The controller owns the wording, because a model has no
// phrases. Returning a phrase key here put the key itself in front of the user.
if (empty($neighbourData))
return error(self::NO_NEIGHBOUR);
$neighbour = $neighbourData[0];
// Transaction wraps the swap; the legacy does not.
$this->db->trans_start();
$this->execQuery(
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
[$neighbour->sort, $current->contentchild_id]
);
$this->execQuery(
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
[$current->sort, $neighbour->contentchild_id]
);
$this->db->trans_complete();
if ($this->db->trans_status() === false)
return error('Sort swap failed');
return success(true);
}
/**
* Writes the whole order of one content in one transaction. Serves the drag and drop.
* The list must hold every child of the content exactly once, or the rows left out
* would keep a stale sort value.
* @param int $content_id
* @param array $contentchild_ids in the wanted order
* @return stdClass success(true) or error
*/
public function setSortOrder($content_id, $contentchild_ids)
{
$currentResult = $this->execReadOnlyQuery(
'SELECT contentchild_id FROM campus.tbl_contentchild WHERE content_id = ?',
[$content_id]
);
if (isError($currentResult))
return $currentResult;
$current = [];
foreach ((array) getData($currentResult) as $row)
$current[] = (int) $row->contentchild_id;
$wanted = array_map('intval', array_values($contentchild_ids));
$check = $wanted;
sort($current);
sort($check);
if ($current !== $check)
return error('Sort order does not match the children of the content');
$this->db->trans_start();
foreach ($wanted as $position => $contentchild_id)
{
$this->execQuery(
'UPDATE campus.tbl_contentchild SET sort = ? WHERE contentchild_id = ?',
[$position + 1, $contentchild_id]
);
}
$this->db->trans_complete();
if ($this->db->trans_status() === false)
return error('Sort order update failed');
return success(true);
}
/**
* Every ancestor of the given contents, with the distance to the content it belongs to.
*
* The tree shows a search result out of its place: a title like "Team" says nothing
* about which unit it belongs to. This delivers the way down to it.
*
* A content can hang under more than one parent, so a level can hold more than one
* ancestor. Every one of them comes back, with tiefe 1 for the direct parent. The
* caller decides how to draw that.
*
* tiefe also ends a cycle: a content whose parent chain leads back to itself would
* otherwise recurse forever.
*
* @param array $content_ids contents to walk up from
* @param int $maxTiefe how many levels to climb
* @param string $sprache language of the ancestor titles
* @return stdClass success with array of rows or error
*/
public function getAncestors($content_ids, $maxTiefe, $sprache)
{
if (empty($content_ids))
return success([]);
$platzhalter = implode(', ', array_fill(0, count($content_ids), '?'));
// The aggregate and the title sit in the final select. The recursive term names
// the working table once and carries no subquery, which is what Postgres allows.
$query = '
WITH RECURSIVE auf(start_id, content_id, tiefe) AS (
SELECT cc.child_content_id, cc.content_id, 1
FROM campus.tbl_contentchild cc
WHERE cc.child_content_id IN (' . $platzhalter . ')
UNION ALL
SELECT auf.start_id, cc.content_id, auf.tiefe + 1
FROM campus.tbl_contentchild cc
JOIN auf ON cc.child_content_id = auf.content_id
WHERE auf.tiefe < ?
)
SELECT auf.start_id, auf.content_id, MIN(auf.tiefe) AS tiefe,
(SELECT titel FROM campus.tbl_contentsprache cs
WHERE cs.content_id = auf.content_id AND cs.sprache = ?
ORDER BY cs.version LIMIT 1) AS titel,
-- The caller turns this into the entitled flag, so the path can offer a
-- link only for an ancestor the editor may open.
(SELECT c.oe_kurzbz FROM campus.tbl_content c
WHERE c.content_id = auf.content_id) AS oe_kurzbz
FROM auf
GROUP BY auf.start_id, auf.content_id
ORDER BY auf.start_id, MIN(auf.tiefe) DESC, auf.content_id
';
$params = array_map('intval', array_values($content_ids));
$params[] = (int) $maxTiefe;
$params[] = $sprache;
return $this->execReadOnlyQuery($query, $params);
}
/**
* Removes a content from the child table, as parent and as child.
* @param int $content_id
* @return stdClass
*/
public function deleteByContent($content_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentchild
WHERE content_id = ? OR child_content_id = ?',
[$content_id, $content_id]
);
}
}
@@ -10,8 +10,6 @@ class Contentgruppe_model extends DB_Model
parent::__construct();
$this->dbTable = 'campus.tbl_contentgruppe';
$this->pk = array('gruppe_kurzbz', 'content_id');
// Composite key without a sequence, so insert() must skip insert_id() and its LASTVAL().
$this->hasSequence = false;
}
/**
@@ -20,7 +18,7 @@ class Contentgruppe_model extends DB_Model
*
* @param int $content_id
*
* @return stdClass success(true) wenn locked sonst success(false)
* @return stdClass success(true) wenn eingeschraenkt sonst success(false)
*/
public function islocked($content_id)
{
@@ -32,7 +30,7 @@ class Contentgruppe_model extends DB_Model
}
/**
* Prüft ob ein User die Berechtigung fuer das Anzeigen des
* Prueft ob ein User die Berechtigung fuer das Anzeigen des
* Contents besitzt
*
* @param int $content_id ID des Contents
@@ -58,89 +56,5 @@ class Contentgruppe_model extends DB_Model
return $result;
return success(!!getData($result));
}
/**
* Groups assigned to a content, with bezeichnung.
* @param int $content_id
* @return stdClass success with array of rows or error
*/
public function getGruppen($content_id)
{
$query = '
SELECT cg.gruppe_kurzbz, g.bezeichnung
FROM campus.tbl_contentgruppe cg
JOIN public.tbl_gruppe g USING (gruppe_kurzbz)
WHERE cg.content_id = ?
ORDER BY cg.gruppe_kurzbz
';
return $this->execReadOnlyQuery($query, [$content_id]);
}
/**
* All selectable groups. Returns gruppe_kurzbz and bezeichnung.
* @return stdClass success with array of rows or error
*/
public function getAllGruppen()
{
// content_visible marks a group as selectable for content rights. Generated groups
// carry FALSE. admin.php filters the same way via getgruppe(...,$content_visible=true).
$query = '
SELECT gruppe_kurzbz, bezeichnung
FROM public.tbl_gruppe
WHERE content_visible = TRUE
ORDER BY gruppe_kurzbz
';
return $this->execReadOnlyQuery($query);
}
/**
* Groups for multiple contents in one query. Returns content_id => [gruppe_kurzbz, ...].
* @param array $contentIds array of content_id values
* @return stdClass success with associative array or error
*/
public function getGruppenForContents($contentIds)
{
if (empty($contentIds))
return success([]);
$placeholders = implode(',', array_fill(0, count($contentIds), '?'));
$query = '
SELECT content_id, gruppe_kurzbz
FROM campus.tbl_contentgruppe
WHERE content_id IN (' . $placeholders . ')
ORDER BY content_id, gruppe_kurzbz
';
$result = $this->execReadOnlyQuery($query, array_values($contentIds));
if (isError($result))
return $result;
$map = [];
if (getData($result))
{
foreach (getData($result) as $row)
{
$id = (int) $row->content_id;
if (!isset($map[$id]))
$map[$id] = [];
$map[$id][] = $row->gruppe_kurzbz;
}
}
return success($map);
}
/**
* @param int $content_id
* @return stdClass
*/
public function deleteByContent($content_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentgruppe WHERE content_id = ?',
[$content_id]
);
}
}
@@ -11,76 +11,4 @@ class Contentlog_model extends DB_Model
$this->dbTable = 'campus.tbl_contentlog';
$this->pk = 'contentlog_id';
}
/**
* The open lock entry of a user (ende IS NULL). Returns null if none exists.
* Match the holder and take the newest, otherwise the age belongs to an abandoned entry.
* @param int $contentsprache_id
* @param string $uid holder of the lock
* @return stdClass success with row or null, or error
*/
public function getOpenLock($contentsprache_id, $uid)
{
$query = '
SELECT contentlog_id, uid, contentsprache_id, start
FROM campus.tbl_contentlog
WHERE contentsprache_id = ? AND uid = ? AND ende IS NULL
ORDER BY start DESC
LIMIT 1
';
$result = $this->execReadOnlyQuery($query, [$contentsprache_id, $uid]);
if (isError($result))
return $result;
$data = getData($result);
return success(empty($data) ? null : $data[0]);
}
/**
* Ends every open lock entry of a version. Without a uid it ends the entry of every
* holder, which is what a take-over and a forced release need.
* @param int $contentsprache_id
* @param string|null $uid
* @return stdClass
*/
public function closeOpenEntries($contentsprache_id, $uid = null)
{
$query = 'UPDATE campus.tbl_contentlog SET ende = now()
WHERE contentsprache_id = ? AND ende IS NULL';
$params = [$contentsprache_id];
if ($uid !== null)
{
$query .= ' AND uid = ?';
$params[] = $uid;
}
return $this->execQuery($query, $params);
}
/**
* @param int $contentsprache_id
* @return stdClass
*/
public function deleteByContentsprache($contentsprache_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id = ?',
[$contentsprache_id]
);
}
/**
* @param int $content_id
* @return stdClass
*/
public function deleteByContent($content_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentlog WHERE contentsprache_id IN
(SELECT contentsprache_id FROM campus.tbl_contentsprache WHERE content_id = ?)',
[$content_id]
);
}
}
@@ -38,177 +38,5 @@ class Contentsprache_model extends DB_Model
return success(!!getData($result));
}
/**
* All versions of a language, ascending by version.
* @param int $content_id
* @param string $sprache
* @return stdClass success with array of rows or error
*/
public function getVersions($content_id, $sprache)
{
$query = '
SELECT contentsprache_id, version, sichtbar, titel,
insertamum, insertvon, updateamum, updatevon, gesperrt_uid
FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?
ORDER BY version ASC
';
return $this->execReadOnlyQuery($query, [$content_id, $sprache]);
}
/**
* Distinct languages for a content. Returns a flat array of strings.
* @param int $content_id
* @return stdClass success with array of strings or error
*/
public function getLanguages($content_id)
{
$query = '
SELECT DISTINCT sprache
FROM campus.tbl_contentsprache
WHERE content_id = ?
ORDER BY sprache
';
$result = $this->execReadOnlyQuery($query, [$content_id]);
if (isError($result))
return $result;
$languages = [];
if (getData($result))
{
foreach (getData($result) as $row)
$languages[] = $row->sprache;
}
return success($languages);
}
/**
* Highest version number. Returns 0 if no row exists.
* @param int $content_id
* @param string $sprache
* @return stdClass success with int or error
*/
public function getMaxVersion($content_id, $sprache)
{
$query = '
SELECT COALESCE(MAX(version), 0) AS max_version
FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?
';
$result = $this->execReadOnlyQuery($query, [$content_id, $sprache]);
if (isError($result))
return $result;
return success((int) getData($result)[0]->max_version);
}
/**
* Row count for a content+language. Used for the delete guard.
* @param int $content_id
* @param string $sprache
* @return stdClass success with int or error
*/
public function getNumberOfVersions($content_id, $sprache)
{
$query = '
SELECT COUNT(*) AS cnt
FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?
';
$result = $this->execReadOnlyQuery($query, [$content_id, $sprache]);
if (isError($result))
return $result;
return success((int) getData($result)[0]->cnt);
}
/**
* One version, all columns. If $version is empty, returns the highest version.
* @param int $content_id
* @param string $sprache
* @param int|null $version
* @return stdClass success with single row or error
*/
public function getOne($content_id, $sprache, $version = null)
{
$params = [$content_id, $sprache];
$versionClause = '';
if (!empty($version))
{
$versionClause = ' AND version = ?';
$params[] = $version;
}
$query = '
SELECT * FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ?' . $versionClause . '
ORDER BY version DESC LIMIT 1
';
$result = $this->execReadOnlyQuery($query, $params);
if (isError($result))
return $result;
$data = getData($result);
if (empty($data))
return error('Version not found');
return success($data[0]);
}
/**
* Clears the lock of a version. Without a uid it clears whoever holds it, which is
* what a forced release needs.
* @param int $contentsprache_id
* @param string|null $uid
* @return stdClass
*/
public function releaseLock($contentsprache_id, $uid = null)
{
$query = 'UPDATE campus.tbl_contentsprache SET gesperrt_uid = NULL
WHERE contentsprache_id = ?';
$params = [$contentsprache_id];
if ($uid !== null)
{
$query .= ' AND gesperrt_uid = ?';
$params[] = $uid;
}
return $this->execQuery($query, $params);
}
/**
* @param int $content_id
* @return stdClass
*/
public function deleteByContent($content_id)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentsprache WHERE content_id = ?',
[$content_id]
);
}
/**
* @param int $content_id
* @param string $sprache
* @param int $version
* @return stdClass
*/
public function deleteVersion($content_id, $sprache, $version)
{
return $this->execQuery(
'DELETE FROM campus.tbl_contentsprache
WHERE content_id = ? AND sprache = ? AND version = ?',
[$content_id, $sprache, $version]
);
}
}
-237
View File
@@ -30,241 +30,4 @@ class Dms_model extends DB_Model
return $returnArray;
}
/**
* Reads a DMS category and the access facts a listing needs.
*
* Mirrors the rule of cms/dms.php and dms.class.php at category level:
* a category is locked when any category in its parent chain grants a group or
* carries a berechtigung_kurzbz. The caller decides, this method only reports.
*
* @param string $kategorie_kurzbz
* gesperrt and in_gruppe come back as 0 or 1, not as a boolean. A PostgreSQL boolean
* reaches PHP as the string 'f' when the driver conversion does not run, and 'f' is
* truthy. The int cast is correct in both cases.
*
* @param string $uid
* @return object one row, or an empty result when the category does not exist
*/
public function getKategorieZugriff($kategorie_kurzbz, $uid)
{
// tiefe guards against a cyclic parent chain.
$query = '
WITH RECURSIVE chain(kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, tiefe) AS (
SELECT kategorie_kurzbz, parent_kategorie_kurzbz, berechtigung_kurzbz, 1
FROM campus.tbl_dms_kategorie
WHERE kategorie_kurzbz = ?
UNION ALL
SELECT k.kategorie_kurzbz, k.parent_kategorie_kurzbz, k.berechtigung_kurzbz, c.tiefe + 1
FROM campus.tbl_dms_kategorie k
JOIN chain c ON k.kategorie_kurzbz = c.parent_kategorie_kurzbz
WHERE c.tiefe < 32
)
SELECT
kat.kategorie_kurzbz,
kat.bezeichnung,
kat.beschreibung,
kat.berechtigung_kurzbz,
(
EXISTS (SELECT 1 FROM chain WHERE chain.berechtigung_kurzbz IS NOT NULL)
OR EXISTS (
SELECT 1
FROM campus.tbl_dms_kategorie_gruppe g
JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz
)
)::int AS gesperrt,
(EXISTS (
SELECT 1
FROM campus.tbl_dms_kategorie_gruppe g
JOIN chain ON chain.kategorie_kurzbz = g.kategorie_kurzbz
JOIN public.tbl_benutzergruppe b ON b.gruppe_kurzbz = g.gruppe_kurzbz
WHERE b.uid = ?
))::int AS in_gruppe
FROM campus.tbl_dms_kategorie kat
WHERE kat.kategorie_kurzbz = ?
';
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz, $uid, $kategorie_kurzbz));
}
/**
* Lists the newest version of every document in one DMS category.
*
* filename stays out of the result. The reader gets the document through
* cms/dms.php?id=<dms_id>, never through the name on disk.
*
* @param string $kategorie_kurzbz
* @return object
*/
public function getKategorieDokumente($kategorie_kurzbz)
{
$query = '
SELECT
d.dms_id,
v.version,
v.name,
v.beschreibung,
v.mimetype,
COALESCE(v.updateamum, v.insertamum) AS geaendert
FROM campus.tbl_dms d
JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id
WHERE d.kategorie_kurzbz = ?
-- cis_suche marks a document as published to CIS readers. A category also
-- holds working files, and those must not appear on a content page.
AND v.cis_suche
AND v.version = (
SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id
)
-- A project document carries its own rule in dms.class.php and does not
-- belong in a CMS category listing.
AND NOT EXISTS (
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
)
ORDER BY LOWER(v.name)
';
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz));
}
/**
* Newest version of the named documents, in no particular order.
*
* Unlike getKategorieDokumente() this does NOT filter on cis_suche. An editor who names
* a dms_id has chosen that document on purpose, and the flag only marks a document for
* the CIS search. The category rule still applies, and the caller enforces it.
*
* @param array $ids dms_id values
* @return object
*/
public function getDokumenteByIds($ids)
{
if (empty($ids))
return success(array());
$platzhalter = implode(', ', array_fill(0, count($ids), '?'));
$query = '
SELECT
d.dms_id,
d.kategorie_kurzbz,
v.version,
v.name,
v.beschreibung,
v.mimetype,
COALESCE(v.updateamum, v.insertamum) AS geaendert
FROM campus.tbl_dms d
JOIN campus.tbl_dms_version v ON v.dms_id = d.dms_id
WHERE d.dms_id IN (' . $platzhalter . ')
AND v.version = (
SELECT MAX(version) FROM campus.tbl_dms_version WHERE dms_id = d.dms_id
)
AND NOT EXISTS (
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
)
';
return $this->execReadOnlyQuery($query, array_values($ids));
}
/**
* Every document of one category, for the editor picker.
*
* Unlike getKategorieDokumente() this does NOT filter on cis_suche, because
* dms-dokumente renders named documents regardless of that flag. The flag travels with
* the row so the picker can mark a document that no reader would find by search.
*
* @param string $kategorie_kurzbz
* @return object
*/
public function getKategorieDokumenteAlle($kategorie_kurzbz)
{
$query = '
SELECT
d.dms_id,
v.name,
v.cis_suche::int AS cis_suche
FROM campus.tbl_dms d
JOIN LATERAL (
SELECT v.name, v.cis_suche
FROM campus.tbl_dms_version v
WHERE v.dms_id = d.dms_id
ORDER BY v.version DESC
LIMIT 1
) v ON TRUE
WHERE d.kategorie_kurzbz = ?
AND NOT EXISTS (
SELECT 1 FROM fue.tbl_projekt_dokument pd WHERE pd.dms_id = d.dms_id
)
ORDER BY LOWER(v.name)
';
return $this->execReadOnlyQuery($query, array($kategorie_kurzbz));
}
/**
* Lists every DMS category with the number of documents it holds.
* Used by the insert dialog of the CMS content component "dms-liste".
*
* @return object
*/
public function getKategorien()
{
$query = '
SELECT
k.kategorie_kurzbz,
k.bezeichnung,
k.oe_kurzbz,
COALESCE(a.anzahl, 0) AS anzahl
FROM campus.tbl_dms_kategorie k
LEFT JOIN (
SELECT d.kategorie_kurzbz, COUNT(*) AS anzahl
FROM campus.tbl_dms d
JOIN (
SELECT DISTINCT ON (dms_id) dms_id, cis_suche
FROM campus.tbl_dms_version
ORDER BY dms_id, version DESC
) v ON v.dms_id = d.dms_id
WHERE v.cis_suche
GROUP BY d.kategorie_kurzbz
) a ON a.kategorie_kurzbz = k.kategorie_kurzbz
ORDER BY LOWER(COALESCE(k.bezeichnung, k.kategorie_kurzbz))
';
return $this->execReadOnlyQuery($query);
}
/**
* The category itself plus its sub categories, down to a depth.
*
* tiefe 0 returns only the category named. The depth bound also ends a cycle: a
* category whose parent chain leads back to itself would otherwise recurse forever.
*
* The caller must still check the entitlement of every category it gets back. A sub
* category carries its own group rule and may be closed while the parent is open.
*
* @param string $kategorie_kurzbz root of the walk
* @param int $tiefe how many levels below the root to include
* @return object success with array of rows or error
*/
public function getMitUnterkategorien($kategorie_kurzbz, $tiefe)
{
$query = '
WITH RECURSIVE unter(kategorie_kurzbz, tiefe) AS (
SELECT kategorie_kurzbz, 0
FROM campus.tbl_dms_kategorie
WHERE kategorie_kurzbz = ?
UNION ALL
SELECT k.kategorie_kurzbz, unter.tiefe + 1
FROM campus.tbl_dms_kategorie k
JOIN unter ON k.parent_kategorie_kurzbz = unter.kategorie_kurzbz
WHERE unter.tiefe < ?
)
SELECT kategorie_kurzbz, MIN(tiefe) AS tiefe
FROM unter
GROUP BY kategorie_kurzbz
ORDER BY MIN(tiefe), kategorie_kurzbz
';
return $this->execReadOnlyQuery($query, [$kategorie_kurzbz, (int) $tiefe]);
}
}
@@ -11,30 +11,4 @@ class Template_model extends DB_Model
$this->dbTable = 'campus.tbl_template';
$this->pk = 'template_kurzbz';
}
/**
* True if the template carries a CIS4 stylesheet of its own, which is not true for every template.
*
* @param string $template_kurzbz
* @return stdClass success(bool) or error
*/
public function hasCis4Stylesheet($template_kurzbz)
{
$query = '
SELECT (xslt_xhtml_c4 IS NOT NULL
AND xslt_xhtml_c4::text IS DISTINCT FROM xslt_xhtml::text) AS eigen
FROM campus.tbl_template
WHERE template_kurzbz = ?
';
$result = $this->execReadOnlyQuery($query, [$template_kurzbz]);
if (isError($result))
return $result;
$data = getData($result);
if (empty($data))
return success(false);
return success($data[0]->eigen === 't' || $data[0]->eigen === true);
}
}
@@ -61,7 +61,7 @@ class Anrechnung_model extends DB_Model
if ($this->db->trans_status() === false)
{
$this->db->trans_rollback();
return error('Failed inserting Anrechnung', EXIT_ERROR);
return error($this->p->t('ui', 'fehlerBeimSpeichern'), EXIT_ERROR);
}
return success($lastInsert_anrechnung_id);
@@ -166,7 +166,7 @@ class Anrechnung_model extends DB_Model
// Get last Anrechnungstatus
if (!$result = getData($this->AnrechnungModel->getLastAnrechnungstatus($anrechnung_id))[0])
{
return error('Failed loading Anrechnung');
return error($this->p->t('ui', 'fehlerBeimLesen'));
}
$last_status = $result->status_kurzbz;
@@ -175,7 +175,7 @@ class Anrechnung_model extends DB_Model
// Exit, if last status is not approved / rejected
if ($last_status != self::ANRECHNUNGSTATUS_APPROVED && $last_status != self::ANRECHNUNGSTATUS_REJECTED)
{
return error('Nothing to withdraw. Application is still in progress');
return error($this->p->t('anrechnung', 'keineGenehmigungZumZuruecknehmen'));
}
// Start DB transaction
@@ -197,7 +197,7 @@ class Anrechnung_model extends DB_Model
if ($this->db->trans_status() === false)
{
$this->db->trans_rollback();
return error('Failed withdrawing Genehmigung', EXIT_ERROR);
return error($this->p->t('anrechnung', 'antragNichtZurueckgenommen'), EXIT_ERROR);
}
return success();
@@ -211,21 +211,31 @@ class Anrechnung_model extends DB_Model
*/
public function getAnrechnungsData($prestudent_id)
{
// Names in the users language, German if no translation exists
$english = getUserLanguage() != 'German';
$language_index = $english ? 2 : 1;
$lv_bezeichnung = $english
? 'COALESCE(NULLIF(lehre.tbl_lehrveranstaltung.bezeichnung_english, \'\'), lehre.tbl_lehrveranstaltung.bezeichnung)'
: 'lehre.tbl_lehrveranstaltung.bezeichnung';
$lv_comp_bezeichnung = $english
? 'COALESCE(NULLIF(lv_comp.bezeichnung_english, \'\'), lv_comp.bezeichnung)'
: 'lv_comp.bezeichnung';
$qry = '
SELECT
lehre.tbl_anrechnung.anrechnung_id,
lehre.tbl_anrechnung.prestudent_id,
lehre.tbl_anrechnung.lehrveranstaltung_id,
lehre.tbl_lehrveranstaltung.bezeichnung AS bez_lehrveranstaltung,
lehre.tbl_anrechnung_begruendung.bezeichnung AS begruendung,
lehre.tbl_anrechnung_anrechnungstatus.status_kurzbz AS status,
' . $lv_bezeichnung . ' AS bez_lehrveranstaltung,
COALESCE(lehre.tbl_anrechnung_begruendung.bezeichnung_mehrsprachig[' . $language_index . '], lehre.tbl_anrechnung_begruendung.bezeichnung) AS begruendung,
COALESCE(lehre.tbl_anrechnungstatus.bezeichnung_mehrsprachig[' . $language_index . '], lehre.tbl_anrechnung_anrechnungstatus.status_kurzbz) AS status,
genehmigt_von,
lehre.tbl_anrechnung.insertamum,
lehre.tbl_anrechnung.insertvon,
lehre.tbl_anrechnung.updateamum,
lehre.tbl_anrechnung.updatevon,
lehrveranstaltung_id_kompatibel,
lv_comp.bezeichnung as lehrveranstaltung_bez_kompatibel,
' . $lv_comp_bezeichnung . ' as lehrveranstaltung_bez_kompatibel,
count(nz.notizzuordnung_id) AS notizen_anzahl
FROM
lehre.tbl_anrechnung
@@ -31,7 +31,7 @@ class Anrechnungszeitraum_model extends DB_Model
if (isError($result))
{
return error('Fehler bei Anrechnungszeitraum speichern.');
return error($this->p->t('ui', 'fehlerBeimSpeichern'));
}
// Return new anrechnungszeitraum_id
@@ -50,7 +50,7 @@ class Anrechnungszeitraum_model extends DB_Model
if (isError($result))
{
return error('Fehler bei Anrechnungszeitraum löschen.');
return error($this->p->t('anrechnung', 'fehlerBeimLoeschen'));
}
return success($result->retval);
@@ -78,7 +78,7 @@ class Anrechnungszeitraum_model extends DB_Model
if (isError($result))
{
return error('Fehler bei Anrechnungszeitraum update.');
return error($this->p->t('ui', 'fehlerBeimSpeichern'));
}
return success($result->retval);
@@ -658,6 +658,26 @@ class Lehrveranstaltung_model extends DB_Model
return $this->execQuery($query, array($uid, $studiensemester_kurzbz, $lehrveranstaltung_id));
}
/**
* Gets the Lehrveranstaltung if it is in a Studienplan of the prestudent.
* Checks the Studienplaene of all statuses, because the status of a future semester can be missing.
* @param $prestudent_id
* @param $lehrveranstaltung_id
* @return array|null
*/
public function getLvInStudienplan($prestudent_id, $lehrveranstaltung_id)
{
$query = '
SELECT DISTINCT spl.lehrveranstaltung_id
FROM public.tbl_prestudentstatus ps
JOIN lehre.tbl_studienplan_lehrveranstaltung spl USING (studienplan_id)
WHERE ps.prestudent_id = ?
AND spl.lehrveranstaltung_id = ?;
';
return $this->execQuery($query, array($prestudent_id, $lehrveranstaltung_id));
}
/**
* Get Lehreinheit.
*
@@ -259,37 +259,4 @@ class Organisationseinheit_model extends DB_Model
return $this->execReadOnlyQuery($qry, array($oe_kurzbz));
}
/**
* The unit itself plus its sub units, down to a depth.
*
* tiefe 0 returns only the unit named, which is what a caller without a depth wants.
* The depth bound also ends a cycle: a unit whose parent chain leads back to itself
* would otherwise recurse forever.
*
* @param string $oe_kurzbz root of the walk
* @param int $tiefe how many levels below the root to include
* @return object success with array of rows or error
*/
public function getMitUntereinheiten($oe_kurzbz, $tiefe)
{
$query = '
WITH RECURSIVE unter(oe_kurzbz, tiefe) AS (
SELECT oe_kurzbz, 0
FROM public.tbl_organisationseinheit
WHERE oe_kurzbz = ?
UNION ALL
SELECT o.oe_kurzbz, unter.tiefe + 1
FROM public.tbl_organisationseinheit o
JOIN unter ON o.oe_parent_kurzbz = unter.oe_kurzbz
WHERE unter.tiefe < ?
)
SELECT oe_kurzbz, MIN(tiefe) AS tiefe
FROM unter
GROUP BY oe_kurzbz
ORDER BY MIN(tiefe), oe_kurzbz
';
return $this->execReadOnlyQuery($query, [$oe_kurzbz, (int) $tiefe]);
}
}
@@ -243,58 +243,6 @@ class Person_model extends DB_Model
* @param $uid
* @return array
*/
/**
* Contact data of the named users, for the CMS content components.
*
* The joins follow Benutzerfunktion_model::getBenutzerFunktionenDetailed() and the view
* Cis/Cms/News/Xml/Address/Detailed.php, so a person reads the same everywhere.
*
* foto_sperre is honoured. A person who blocked their photo never gets one on a public
* content page, no matter what the page asks for.
*
* @param array $uids
* @param boolean $mitFoto photos are large, so they are read only on request
* @return object
*/
public function getCmsKontakt($uids, $mitFoto = false)
{
if (empty($uids))
return success(array());
$platzhalter = implode(', ', array_fill(0, count($uids), '?'));
$fotoSpalte = $mitFoto
? 'CASE WHEN p.foto_sperre THEN NULL ELSE p.foto END'
: 'NULL';
$query = '
SELECT
b.uid,
b.alias,
p.vorname,
p.nachname,
p.titelpre,
p.titelpost,
m.telefonklappe,
k.kontakt,
o.planbezeichnung,
' . $fotoSpalte . ' AS foto
FROM public.tbl_benutzer b
JOIN public.tbl_person p ON p.person_id = b.person_id
LEFT JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid
LEFT JOIN public.tbl_kontakt k
ON k.standort_id = m.standort_id AND k.kontakttyp = ?
LEFT JOIN public.tbl_ort o ON o.ort_kurzbz = m.ort_kurzbz
WHERE b.uid IN (' . $platzhalter . ')
AND b.aktiv
';
$parameter = array('telefon');
foreach ($uids as $uid)
$parameter[] = $uid;
return $this->execReadOnlyQuery($query, $parameter);
}
public function getByUid($uid)
{
$this->addSelect('vorname, nachname, gebdatum, person_id, bpk, matr_nr, foto');
@@ -98,31 +98,6 @@ class Mitarbeiter_model extends DB_Model
return $this->execQuery($qry, $params);
}
/**
* Active staff, names only, for a picker.
*
* getPersonal() returns tbl_person.* including svnr, bpk and
* gebdatum, which is not needed in a CMS endpoint.
*
* @return object
*/
public function getAktivesPersonalNamen()
{
$query = '
SELECT
b.uid,
p.vorname,
p.nachname
FROM public.tbl_benutzer b
JOIN public.tbl_mitarbeiter m ON m.mitarbeiter_uid = b.uid
JOIN public.tbl_person p ON p.person_id = b.person_id
WHERE b.aktiv
ORDER BY LOWER(p.nachname), LOWER(p.vorname)
';
return $this->execReadOnlyQuery($query);
}
/**
* gibt Personen mit Übersicht von Vertragsdaten aus
*
@@ -12,72 +12,4 @@ class Webservicelog_model extends DB_Model
$this->dbTable = 'system.tbl_webservicelog';
$this->pk = 'webservicelog_id';
}
/**
* The most viewed contents since a date, with the title of each one.
* request_id is a varchar, so the join casts the content_id to text. A cast of
* request_id to a number would fail on a row that holds no number.
* The join also drops a log row whose content is deleted.
* @param string|null $since timestamp in the form Y-m-d H:i:s, null for the whole log
* @param string $sprache language of the title
* @param int $limit length of the ranking
* @return stdClass success with array of rows or error
*/
public function getContentClickCounts($since, $sprache, $limit)
{
$params = [$sprache, 'content'];
$sinceCondition = '';
if ($since !== null)
{
$sinceCondition = ' AND w.execute_time >= ?';
$params[] = $since;
}
$params[] = $limit;
$query = '
SELECT c.content_id, COUNT(*) AS hits,
(SELECT titel FROM campus.tbl_contentsprache
WHERE content_id = c.content_id AND sprache = ?
ORDER BY version LIMIT 1) AS titel
FROM system.tbl_webservicelog w
JOIN campus.tbl_content c ON c.content_id::text = w.request_id
WHERE w.webservicetyp_kurzbz = ?' . $sinceCondition . '
GROUP BY c.content_id
ORDER BY COUNT(*) DESC, c.content_id
LIMIT ?
';
return $this->execReadOnlyQuery($query, $params);
}
/**
* Views of one content since a date. A content outside the ranking still needs its
* own number.
* @param int $content_id
* @param string|null $since timestamp in the form Y-m-d H:i:s, null for the whole log
* @return stdClass success with int or error
*/
public function getContentClickCount($content_id, $since)
{
$query = '
SELECT COUNT(*) AS hits
FROM system.tbl_webservicelog
WHERE webservicetyp_kurzbz = ? AND request_id = ?
';
$params = ['content', (string) $content_id];
if ($since !== null)
{
$query .= ' AND execute_time >= ?';
$params[] = $since;
}
$result = $this->execReadOnlyQuery($query, $params);
if (isError($result))
return $result;
return success((int) getData($result)[0]->hits);
}
}
@@ -23,7 +23,6 @@ $includesArray = array(
'public/css/components/FormUnderline.css',
'public/css/components/abgabetool/abgabe.css',
'public/css/Cis4/Cms.css',
'public/css/Cis4/Contentcomponents.css',
'public/css/Cis4/Studium.css'
),
'customJSs' => array(
-35
View File
@@ -1,35 +0,0 @@
<?php
$includesArray = array(
'title' => 'CMS',
'axios027' => true,
'bootstrap5' => true,
'fontawesome6' => true,
'vue3' => true,
'primevue3' => true,
'tabulator6' => true,
'tinymce5' => true,
'phrases' => array('global', 'ui', 'cms'),
'customCSSs' => [
'public/css/components/verticalsplit.css',
'public/css/components/horizontalsplit.css',
'public/css/Cms.css'
],
'customJSs' => [
'vendor/npm-asset/primevue/tree/tree.min.js',
'vendor/npm-asset/primevue/checkbox/checkbox.min.js',
'vendor/npm-asset/primevue/textarea/textarea.min.js',
'vendor/moment/luxonjs/luxon.min.js'
],
'customJSModules' => ['public/js/apps/Cms.js']
);
$this->load->view('templates/FHC-Header', $includesArray);
?>
<div id="main">
<router-view
cms-root="<?= site_url('cms'); ?>"
auth-uid="<?= getAuthUID(); ?>"
default-language="<?= defined('DEFAULT_LANGUAGE') ? DEFAULT_LANGUAGE : 'German'; ?>"
<?= !empty($clickstats) ? 'clickstats' : ''; ?>
></router-view>
</div>
<?php $this->load->view('templates/FHC-Footer', $includesArray); ?>
@@ -19,12 +19,14 @@ $includesArray = array(
'anrechnungszeitraumHinzufuegen',
'anrechnungszeitraumSpeichern',
'anrechnungszeitraumStart',
'anrechnungszeitraumEnde'
'anrechnungszeitraumEnde',
'anrechnungszeitraumBearbeiten'
),
'ui' => array(
'aktion',
'geloescht',
'gespeichert',
'systemfehler',
'frageSicherLoeschen',
'spaltenEinstellen'
),
@@ -113,7 +115,7 @@ if (defined("CIS4")) {
<input type="hidden" id="defaultStudiensemester_kurzbz"
value="<?php echo $studiensemester_kurzbz ?>">
<div class="col-xs-4">
<label for="studiensemester" class="small">Studiensemester</label>
<label for="studiensemester" class="small"><?php echo ucfirst($this->p->t('lehre', 'studiensemester')); ?></label>
<?php
echo $this->widgetlib->widget(
'Studiensemester_widget',
@@ -128,11 +130,11 @@ if (defined("CIS4")) {
?>
</div>
<div class="col-xs-4">
<label for="azrStart" class="small">Anr.-Zeitraum Start</label>
<label for="azrStart" class="small"><?php echo $this->p->t('anrechnung', 'anrechnungszeitraumStart'); ?></label>
<input type="date" id="azrStart" value="" class="form-control" required>
</div>
<div class="col-xs-4">
<label for="azrEnde" class="small">Anr.-Zeitraum Ende</label>
<label for="azrEnde" class="small"><?php echo $this->p->t('anrechnung', 'anrechnungszeitraumEnde'); ?></label>
<input type="date" id="azrEnde" value="" class="form-control" required>
</div>
</div>
@@ -7,7 +7,7 @@ $includesArray = array(
'jqueryui1' => true,
'bootstrap5' => true,
'cis' => true,
'fontawesome4' => true,
'fontawesome6' => true,
'ajaxlib' => true,
'dialoglib' => true,
'phrases' => array(
@@ -47,7 +47,9 @@ $includesArray = array(
'erfolgreichZurueckgenommen',
'empfehlungPositivConfirmed',
'empfehlungNegativConfirmed',
'anrechnungEctsTooltipTextBeiUeberschreitung'
'anrechnungEctsTooltipTextBeiUeberschreitung',
'hoechstgrenzeUeberschritten',
'browserNichtUnterstuetzt'
)
),
'customCSSs' => array(
@@ -161,12 +163,12 @@ if (defined("CIS4")) {
</span>
</th>
<td>
Total: <span
<?php echo $this->p->t('anrechnung', 'totalEcts'); ?>: <span
id="sumEctsTotal"><?php echo number_format($antragData->sumEctsSchulisch + $antragData->sumEctsBeruflich, 1) ?></span>
[Schulisch: <span id="sumEctsSchulisch"
[<?php echo $this->p->t('anrechnung', 'schulisch'); ?>: <span id="sumEctsSchulisch"
value="<?php echo $antragData->sumEctsSchulisch ?>"><?php echo $antragData->sumEctsSchulisch ?></span>
/
Beruflich: <span id="sumEctsBeruflich"
<?php echo $this->p->t('anrechnung', 'beruflich'); ?>: <span id="sumEctsBeruflich"
value="<?php echo $antragData->sumEctsBeruflich ?>"><?php echo $antragData->sumEctsBeruflich ?></span>
]
<div class="p-1 align-items-center" id="sumEctsMsg"></div>
@@ -498,7 +500,7 @@ if (defined("CIS4")) {
<div class="col-4">
<!-- -Statusleiste -->
<div class="mb-5 alert text-center">
Status: <b><span class="text-uppercase" id="approveAnrechnungDetail-status_kurzbz"
<?php echo $this->p->t('global', 'status'); ?>: <b><span class="text-uppercase" id="approveAnrechnungDetail-status_kurzbz"
data-status_kurzbz="<?php echo $anrechnungData->status_kurzbz ?>">
<?php echo $anrechnungData->status; ?>
</span></b>
@@ -5,7 +5,7 @@ $includesArray = array(
'jquery3' => true,
'jqueryui1' => true,
'bootstrap5' => true,
'fontawesome4' => true,
'fontawesome6' => true,
'tabulator5' => true,
'tabulator5JQuery' => true,
'ajaxlib' => true,
@@ -15,7 +15,9 @@ $includesArray = array(
'phrases' => array(
'global' => array(
'begruendung',
'zgv'
'zgv',
'details',
'status'
),
'anrechnung' => array(
'nachweisdokumente',
@@ -23,7 +25,9 @@ $includesArray = array(
'empfehlungsanfrageAn',
'empfehlungsanfrageAm',
'confirmTextAntragHatBereitsEmpfehlung',
'herkunft'
'herkunft',
'ectsSummeKurz',
'browserNichtUnterstuetzt'
),
'ui' => array(
'anzeigen',
@@ -137,7 +141,8 @@ if (defined("CIS4")) {
</div>
<button type="submit"
class="btn btn-outline-secondary col-auto"><?php echo ucfirst($this->p->t('ui', 'anzeigen')); ?></button>
class="btn btn-outline-secondary col-auto"><?php echo ucfirst($this->p->t('ui', 'anzeigen')); ?>
</button>
</form>
</div>
</div>
@@ -175,9 +180,11 @@ if (defined("CIS4")) {
</li>
<li class="list-group-item list-group-item-secondary"><?php echo $this->p->t('anrechnung', 'genehmigungNegativKenntnisseNichtGleichwertigWeilHinweis'); ?></li>
</ol>
<textarea class="form-control" name="begruendung" id="approveAnrechnungUebersicht-begruendung"
<textarea class="form-control"
name="begruendung" id="approveAnrechnungUebersicht-begruendung"
rows="2"
placeholder="<?php echo $this->p->t('anrechnung', 'textUebernehmenOderEigenenBegruendungstext'); ?>" required></textarea>
placeholder="<?php echo $this->p->t('anrechnung', 'textUebernehmenOderEigenenBegruendungstext'); ?>"
required></textarea>
</div>
<!-- Action Button 'Abbrechen'-->
@@ -227,17 +234,17 @@ if (defined("CIS4")) {
<button id="show-inProgressLektor" class="btn btn-outline-secondary btn-clearfilter" type="button"
data-bs-toggle="tooltip" data-bs-placement="left"
title="<?php echo $this->p->t('ui', 'alleInBearbeitungLektor'); ?>"><i
class='fa fa-clock-o'></i>
class='fa-regular fa-clock'></i>
</button>
<button id="show-recommended" class="btn btn-outline-secondary btn-clearfilter" type="button"
data-bs-toggle="tooltip" data-bs-placement="left"
title="<?php echo $this->p->t('ui', 'nurEmpfohleneAnzeigen'); ?>"><i
class='fa fa-thumbs-o-up'></i>
class='fa-regular fa-thumbs-up'></i>
</button>
<button id="show-not-recommended" class="btn btn-outline-secondary btn-clearfilter" type="button"
data-bs-toggle="tooltip" data-bs-placement="left"
title="<?php echo $this->p->t('ui', 'nurNichtEmpfohleneAnzeigen'); ?>"><i
class='fa fa-thumbs-o-down'></i>
class='fa-regular fa-thumbs-down'></i>
</button>
<button id="show-approved" class="btn btn-outline-secondary btn-clearfilter" type="button"
data-bs-toggle="tooltip" data-bs-placement="left"
@@ -6,6 +6,9 @@ $STUDIENSEMESTER = $studiensemester_selected;
$STUDIENGAENGE_ENTITLED = implode(', ', $studiengaenge_entitled); // alle STG mit Lese- und Schreibberechtigung
$ORGANISATIONSEINHEITEN_SCHREIBBERECHTIGT = "'" . implode('\',\'', $oes_schreibberechtigt) . "'"; // alle OE nur mit Schreibberechtigung; singlequote für jeden string notwendig
$LANGUAGE_INDEX = getUserLanguage() == 'German' ? '1' : '2';
// English names only if the Studiengang or LV has one
$STG_BEZEICHNUNG = getUserLanguage() == 'German' ? 'stg.bezeichnung' : 'COALESCE(NULLIF(stg.english, \'\'), stg.bezeichnung)';
$LV_BEZEICHNUNG = getUserLanguage() == 'German' ? 'lv.bezeichnung' : 'COALESCE(NULLIF(lv.bezeichnung_english, \'\'), lv.bezeichnung)';
$query = '
WITH anrechnungen AS
@@ -21,7 +24,7 @@ $query = '
END "schreibberechtigt",
anrechnung.studiensemester_kurzbz,
stg.studiengang_kz,
stg.bezeichnung AS stg_bezeichnung,
' . $STG_BEZEICHNUNG . ' AS stg_bezeichnung,
lv.orgform_kurzbz,
(SELECT ausbildungssemester
FROM public.tbl_prestudentstatus press
@@ -31,12 +34,12 @@ $query = '
ORDER BY press.datum DESC
LIMIT 1
),
lv.bezeichnung AS lv_bezeichnung,
' . $LV_BEZEICHNUNG . ' AS lv_bezeichnung,
lv.ects::numeric(4,1),
get_ects_summe_schulisch(student.student_uid, anrechnung.prestudent_id, stg.studiengang_kz) AS ectsSumSchulisch,
get_ects_summe_beruflich(student.student_uid) AS ectsSumBeruflich,
(person.nachname || \' \' || person.vorname) AS "student",
begruendung.bezeichnung AS "begruendung",
COALESCE(begruendung.bezeichnung_mehrsprachig[' . $LANGUAGE_INDEX . '], begruendung.bezeichnung) AS "begruendung",
dmsversion.name AS "dokument_bezeichnung",
anrechnung.anmerkung_student,
(SELECT COALESCE(
@@ -178,17 +181,17 @@ $filterWidgetArray = array(
'lehrveranstaltung_id',
'begruendung_id',
'dms_id',
'Schreibberechtigt',
$this->p->t('anrechnung', 'schreibberechtigt'),
'studiensemester_kurzbz',
'studiengang_kz',
ucfirst($this->p->t('lehre', 'studiengang')),
ucfirst($this->p->t('lehre', 'organisationsform')),
'Semester',
$this->p->t('lehre', 'semester'),
ucfirst($this->p->t('lehre', 'lehrveranstaltung')),
'ECTS (LV)',
'ECTS (LV + Bisher)',
'ECTS (Bisher schulisch)',
'ECTS (Bisher beruflich',
$this->p->t('anrechnung', 'ectsLv'),
$this->p->t('anrechnung', 'ectsLvUndBisher'),
$this->p->t('anrechnung', 'ectsBisherSchulisch'),
$this->p->t('anrechnung', 'ectsBisherBeruflich'),
ucfirst($this->p->t('global', 'begruendung')),
ucfirst($this->p->t('person', 'studentIn')),
ucfirst($this->p->t('anrechnung', 'nachweisdokumente')),
@@ -197,7 +200,7 @@ $filterWidgetArray = array(
ucfirst($this->p->t('anrechnung', 'antragdatum')),
ucfirst($this->p->t('anrechnung', 'empfehlung')),
'status_kurzbz',
'Status',
$this->p->t('global', 'status'),
'PrestudentID',
ucfirst($this->p->t('anrechnung', 'empfehlungsanfrageAm')),
ucfirst($this->p->t('anrechnung', 'empfehlungsanfrageAn'))
@@ -5,7 +5,7 @@ $includesArray = array(
'jquery3' => true,
'jqueryui1' => true,
'bootstrap5' => true,
'fontawesome4' => true,
'fontawesome6' => true,
'ajaxlib' => true,
'dialoglib' => true,
'tabulator5' => true,
@@ -20,7 +20,9 @@ $includesArray = array(
'antragBearbeiten'
),
'ui' => array(
'hochladen'
'hochladen',
'errorDokumentZuGross',
'systemfehler'
),
'lehre' => array(
'studiensemester',
@@ -3,13 +3,15 @@
$STUDIENSEMESTER = $studiensemester_selected;
$STUDIENGAENGE_ENTITLED = implode(', ', $studiengaenge_entitled);
$LANGUAGE_INDEX = getUserLanguage() == 'German' ? '1' : '2';
// English name only if the Studiengang has one
$STG_BEZEICHNUNG = getUserLanguage() == 'German' ? 'stg.bezeichnung' : 'COALESCE(NULLIF(stg.english, \'\'), stg.bezeichnung)';
$query = '
SELECT pst.prestudent_id,
person.person_id,
pststatus.studienplan_id,
stg.studiengang_kz,
stg.bezeichnung AS "stg_bezeichnung",
' . $STG_BEZEICHNUNG . ' AS "stg_bezeichnung",
ausbildungssemester,
nachname,
vorname,
@@ -47,7 +49,7 @@ $filterWidgetArray = array(
'studienplan_id',
'studiengang_kz',
ucfirst($this->p->t('lehre', 'studiengang')),
'Semester',
$this->p->t('lehre', 'semester'),
ucfirst($this->p->t('person', 'nachname')),
ucfirst($this->p->t('person', 'vorname')),
ucfirst($this->p->t('global', 'zgv'))
@@ -11,7 +11,7 @@ $includesArray = array(
'jquery3' => true,
'jqueryui1' => true,
'bootstrap5' => true,
'fontawesome4' => true,
'fontawesome6' => true,
'ajaxlib' => true,
'dialoglib' => true,
'cis'=>true,
@@ -36,8 +36,10 @@ $includesArray = array(
'anrechnung' => array(
'deadlineUeberschritten',
'benotungDerLV',
'lvNichtImStudienplan',
'anrechnungEctsTextBeiUeberschreitung',
'anrechnungEctsTooltipTextBeiUeberschreitung'
'anrechnungEctsTooltipTextBeiUeberschreitung',
'hoechstgrenzeUeberschritten'
),
'person' => array(
'student',
@@ -158,9 +160,9 @@ else
</div>
</th>
<td colspan="3">
Total ECTS: <span id="sumEctsTotal"><?php echo number_format($antragData->sumEctsSchulisch + $antragData->sumEctsBeruflich, 1) ?></span>
[ Schulisch: <span id="sumEctsSchulisch"><?php echo $antragData->sumEctsSchulisch ?></span> |
Beruflich: <span id="sumEctsBeruflich"><?php echo $antragData->sumEctsBeruflich ?></span> ]
<?php echo $this->p->t('anrechnung', 'totalEcts'); ?>: <span id="sumEctsTotal"><?php echo number_format($antragData->sumEctsSchulisch + $antragData->sumEctsBeruflich, 1) ?></span>
[ <?php echo $this->p->t('anrechnung', 'schulisch'); ?>: <span id="sumEctsSchulisch"><?php echo $antragData->sumEctsSchulisch ?></span> |
<?php echo $this->p->t('anrechnung', 'beruflich'); ?>: <span id="sumEctsBeruflich"><?php echo $antragData->sumEctsBeruflich ?></span> ]
<div class="p-1 align-items-center" id="requestAnrechnung-maxEctsUeberschrittenMsg"></div>
</td>
</tr>
@@ -184,43 +186,31 @@ else
<div class="col-lg-12">
<div class="border border-dark border-3 rounded p-3" >
<p ><?php echo $this->p->t('anrechnung', 'antragStellenText'); ?></p>
<div class="ps-3 ">
<div class="form-check mb-1">
<input class="form-check-input" type="radio" name="begruendung" value="1" <?php echo $anrechnungData->begruendung_id == '1' ? 'checked' : ''; ?> required />
<?php echo $this->p->t('anrechnung', 'antragStellenWegenZeugnis'); ?>&emsp;
<div class="d-inline" id="requestAnrechnung-anrechnungGrundZeugnisTooltip" data-bs-toggle="tooltip" data-bs-placement="right" data-bs-html="true"
data-bs-title="<?php echo $this->p->t('anrechnung', 'anrechnungGrundZeugnisTooltipText'); ?>" >
<i class="fa fa-lg fa-info-circle" aria-hidden="true"></i>
</div>
</div>
<div class="form-check mb-1">
<input class="form-check-input" type="radio" name="begruendung" value="5" <?php echo $anrechnungData->begruendung_id == '5' ? 'checked' : ''; ?> required />
<?php echo $this->p->t('anrechnung', 'antragStellenWegenHochschulzeugnis'); ?>&emsp;
<div class="d-inline" id="requestAnrechnung-anrechnungGrundHochschulzeugnisTooltip" data-bs-toggle="tooltip" data-bs-placement="right" data-bs-html="true"
data-bs-title="<?php echo $this->p->t('anrechnung', 'anrechnungGrundZeugnisTooltipText'); ?>">
<i class="fa fa-lg fa-info-circle" aria-hidden="true"></i>
</div>
</div>
<div class="form-check mb-1">
<input class="form-check-input" type="radio" name="begruendung" value="4" <?php echo $anrechnungData->begruendung_id == '4' ? 'checked' : ''; ?> required />
<?php echo $this->p->t('anrechnung', 'antragStellenWegenPraxis'); ?>&emsp;
<div class="d-inline" id="requestAnrechnung-anrechnungGrundBerufTooltip" data-bs-toggle="tooltip" data-bs-placement="right" data-bs-html="true"
data-bs-title="<?php echo $this->p->t('anrechnung', 'anrechnungGrundBerufTooltipText'); ?>">
<i class="fa fa-lg fa-info-circle" aria-hidden="true"></i>
</div>
</div>
</div>
</div>
</div>
@@ -285,7 +275,7 @@ else
required>
<div class="mx-4 " id="requestAnrechnung-uploadTooltip" data-bs-toggle="tooltip" data-bs-placement="right" data-bs-html="true"
data-bs-title="<?php echo $this->p->t('ui', 'uploadTooltipText'); ?>">
<i class="fa fa-lg fa-question-circle-o" aria-hidden="true"></i>
<i class="fa-regular fa-lg fa-circle-question" aria-hidden="true"></i>
</div>
<a class="mx-4 float-end <?php echo !empty($anrechnungData->dms_id) ? '' : 'visually-hidden' ?>"
id="requestAnrechnung-downloadDocLink"
@@ -293,9 +283,6 @@ else
target="_blank"><?php echo htmlentities($anrechnungData->dokumentname) ?>
</a>
</div>
</div>
</div>
</div>
@@ -303,38 +290,35 @@ else
<!-- Herkunft der Kenntnisse -->
<div class="row mb-3">
<div class="col-lg-12">
<div class="card">
<div class="card-header">
<span class="fw-bold"><?php echo $this->p->t('anrechnung', 'herkunftDerKenntnisse'); ?></span>&emsp;
<div class="d-inline" data-bs-toggle="tooltip" data-bs-placement="right" data-bs-html="true" data-bs-title="<?php echo $this->p->t('anrechnung', 'anrechnungInfoTooltipText'); ?>">
<i class="fa fa-lg fa-info-circle" aria-hidden="true"></i>
</div>
</div>
<div class="card-body">
<textarea class="form-control" name="anmerkung" rows="1" id="requestAnrechnung-herkunftDerKenntnisse"
maxlength="<?php echo CHAR_LENGTH125 ?>" required><?php echo $anrechnungData->anmerkung; ?></textarea>
<small><span class="text-muted float-end"><?php echo $this->p->t('ui', 'maxZeichen'); ?> :<span id="requestAnrechnung-herkunftDerKenntnisse-charCounter"><?php echo CHAR_LENGTH125 ?></span></span></small>
</div>
<div class="card">
<div class="card-header">
<span class="fw-bold"><?php echo $this->p->t('anrechnung', 'herkunftDerKenntnisse'); ?></span>&emsp;
<div class="d-inline" data-bs-toggle="tooltip" data-bs-placement="right" data-bs-html="true" data-bs-title="<?php echo $this->p->t('anrechnung', 'anrechnungInfoTooltipText'); ?>">
<i class="fa fa-lg fa-info-circle" aria-hidden="true"></i>
</div>
</div>
<div class="card-body">
<textarea class="form-control" name="anmerkung" rows="1" id="requestAnrechnung-herkunftDerKenntnisse"
maxlength="<?php echo CHAR_LENGTH125 ?>" required><?php echo $anrechnungData->anmerkung; ?></textarea>
<small><span class="text-muted float-end"><?php echo $this->p->t('ui', 'maxZeichen'); ?> :<span id="requestAnrechnung-herkunftDerKenntnisse-charCounter"><?php echo CHAR_LENGTH125 ?></span></span></small>
</div>
</div>
</div>
</div>
<!-- Bestaetigung-->
<div class="row mb-3">
<div class="col-lg-12">
<div class="border border-3 rounded border-dark p-3" >
<div class="form-check">
<div class="form-check">
<input class="form-check-input border-3" type="checkbox" name="bestaetigung" required>
<small class=" fw-bold"><?php echo $this->p->t('anrechnung', 'bestaetigungstext'); ?></small>
</div>
</div>
</div>
</div>
</div>
<!-- Button 'Anrechnung beantragen'-->
<div class="float-end">
<input type="submit" id="requestAnrechnung-apply-anrechnung" class="btn btn-primary"
<input type="submit" id="requestAnrechnung-apply-anrechnung" class="btn btn-primary"
value="<?php echo $this->p->t('anrechnung', 'anrechnungBeantragen'); ?>">
</div>
</form>
@@ -343,16 +327,18 @@ else
</div>
<div class="col-4">
<!-- Status panel -->
<div class="alert text-center" id="requestAnrechnung-status">Status: <span class="fw-bold text-uppercase" id="requestAnrechnung-status_kurzbz"
data-status_kurzbz="<?php echo $anrechnungData->status_kurzbz ?>">
<?php echo $anrechnungData->status; ?>
</span>
<div class="alert text-center" id="requestAnrechnung-status"><?php echo $this->p->t('global', 'status'); ?>:
<span class="fw-bold text-uppercase" id="requestAnrechnung-status_kurzbz"
data-status_kurzbz="<?php echo $anrechnungData->status_kurzbz ?>">
<?php echo $anrechnungData->status; ?>
</span>
</div>
<!-- Sperregrund panel (hidden by default) -->
<div class="alert bg-danger-subtle text-center visually-hidden" id="requestAnrechnung-sperre"
data-anrechnung_id="<?php echo empty($anrechnungData->anrechnung_id) ? '' : $anrechnungData->anrechnung_id; ?>"
data-expired="<?php echo json_encode($is_expired); ?>"
data-blocked="<?php echo json_encode($is_blocked) ?>">
data-blocked="<?php echo json_encode($is_blocked) ?>"
data-not_in_studienplan="<?php echo json_encode($is_not_in_studienplan) ?>">
</div>
<?php $this->load->view('lehre/anrechnung/requestAnrechnungImportant'); ?>
</div>
@@ -3,10 +3,10 @@
<?php if (isset($this->config->item('display_infobox')['fristen']) && $this->config->item('display_infobox')['fristen'] === true): ?>
<div class="accordion-item">
<h2 class="accordion-header">
<div class="bg-info-subtle accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#Beantragung" aria-expanded="true" aria-controls="Beantragung">
<div class="d-flex">
<div class="d-flex">
<div class="bg-info-subtle accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#Beantragung" aria-expanded="true" aria-controls="Beantragung">
<i class="me-2 fa fa-lg fa-info-circle" aria-hidden="true"></i>&ensp;
<?php echo $this->p->t('anrechnung', 'requestAnrechnungInfoFristenTitle'); ?>
</div>
@@ -23,9 +23,10 @@
<?php if (isset($this->config->item('display_infobox')['referenzbeispiele_ects']) && $this->config->item('display_infobox')['referenzbeispiele_ects'] === true): ?>
<div class="accordion-item">
<h2 class="accordion-header">
<div class="bg-info-subtle accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#Referenzbeispiele" aria-expanded="false" aria-controls="Referenzbeispiele">
<div class="d-flex">
<div class="d-flex">
<div class="bg-info-subtle accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#Referenzbeispiele" aria-expanded="false" aria-controls="Referenzbeispiele">
<i class="me-2 fa fa-lg fa-info-circle" aria-hidden="true"></i>&ensp;
<?php echo $this->p->t('anrechnung', 'requestAnrechnungInfoEctsBerechnungTitle'); ?>
</div>
@@ -63,9 +64,10 @@
<?php if (isset($this->config->item('display_infobox')['herkunft_kenntnisse']) && $this->config->item('display_infobox')['herkunft_kenntnisse'] === true): ?>
<div class="accordion-item">
<h2 class="accordion-header">
<div class="bg-info-subtle accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#HerkunftKenntnisse" aria-expanded="false" aria-controls="HerkunftKenntnisse">
<div class="d-flex">
<div class="d-flex">
<div class="bg-info-subtle accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#HerkunftKenntnisse" aria-expanded="false" aria-controls="HerkunftKenntnisse">
<i class="me-2 fa fa-lg fa-info-circle" aria-hidden="true"></i>&ensp;
<?php echo $this->p->t('anrechnung', 'requestAnrechnungInfoHerkunftKenntnisseTitle'); ?>
</div>
@@ -7,7 +7,7 @@ $includesArray = array(
'jqueryui1' => true,
'bootstrap5' => true,
'cis' => true,
'fontawesome4' => true,
'fontawesome6' => true,
'ajaxlib' => true,
'dialoglib' => true,
'phrases' => array(
@@ -39,7 +39,8 @@ $includesArray = array(
),
'anrechnung' => array(
'empfehlungPositivConfirmed',
'empfehlungNegativConfirmed'
'empfehlungNegativConfirmed',
'browserNichtUnterstuetzt'
)
),
'customCSSs' => array(
@@ -151,7 +152,6 @@ if (defined("CIS4")) {
<div class="col-lg-8">
<table class="table table-bordered table-condensed table-fixed mb-0">
<tbody>
<tr>
<th class="col-4">
<?php echo ucfirst($this->p->t('global', 'zgv')); ?>
@@ -191,7 +191,6 @@ if (defined("CIS4")) {
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
@@ -214,7 +213,6 @@ if (defined("CIS4")) {
<?php echo $this->p->t('anrechnung', 'empfehlungdatum'); ?>:
<span
id="reviewAnrechnungDetail-empfehlungAm"><?php echo $empfehlungData->empfehlung_am ?></span>
</div>
</div>
<div class="card-body">
@@ -380,11 +378,11 @@ if (defined("CIS4")) {
<div class="col-4 ">
<div class="mb-5 alert text-center">
Status:
<?php echo $this->p->t('global', 'status'); ?>:
<b><span class="text-uppercase" id="reviewAnrechnungDetail-status_kurzbz"
data-status_kurzbz="<?php echo $anrechnungData->status_kurzbz ?>">
<?php echo $anrechnungData->status; ?>
</span></b>
data-status_kurzbz="<?php echo $anrechnungData->status_kurzbz ?>">
<?php echo $anrechnungData->status; ?>
</span></b>
</div>
<?php $this->load->view('lehre/anrechnung/reviewAnrechnungInfo'); ?>
@@ -5,7 +5,7 @@ $includesArray = array(
'jquery3' => true,
'jqueryui1' => true,
'bootstrap5' => true,
'fontawesome4' => true,
'fontawesome6' => true,
'tabulator5' => true,
'tabulator5JQuery' => true,
'cis' => true,
@@ -15,12 +15,15 @@ $includesArray = array(
'phrases' => array(
'global' => array(
'begruendung',
'zgv'
'zgv',
'details',
'status'
),
'anrechnung' => array(
'nachweisdokumente',
'empfehlung',
'herkunft'
'herkunft',
'browserNichtUnterstuetzt'
),
'ui' => array(
'anzeigen',
@@ -103,7 +106,8 @@ if (defined("CIS4")) {
<h3 class="fw-normal">
<?php echo $this->p->t('anrechnung', 'anrechnungenPruefen'); ?>
<small class="text-secondary fs-6">|
<?php echo ucfirst($this->p->t('global', 'uebersicht')); ?></small>
<?php echo ucfirst($this->p->t('global', 'uebersicht')); ?>
</small>
</h3>
</div>
</div>
@@ -127,7 +131,8 @@ if (defined("CIS4")) {
?>
</div>
<button type="submit"
class="btn btn-outline-secondary col-auto"><?php echo ucfirst($this->p->t('ui', 'anzeigen')); ?></button>
class="btn btn-outline-secondary col-auto"><?php echo ucfirst($this->p->t('ui', 'anzeigen')); ?>
</button>
</form>
</div>
</div>
@@ -145,10 +150,12 @@ if (defined("CIS4")) {
<?php echo $this->p->t('anrechnung', 'empfehlungenNegativQuestion'); ?>
</h4>
<div class="mb-4">
<b><span>&ensp;<?php echo $this->p->t('anrechnung', 'bitteBegruendungAngeben'); ?></span>
<b>
<span>&ensp;<?php echo $this->p->t('anrechnung', 'bitteBegruendungAngeben'); ?></span>
<span class="text-danger">
<?php echo $this->p->t('anrechnung', 'begruendungWirdFuerAlleUebernommen'); ?>
</span></b>
</span>
</b>
</div>
<ul class="list-group mb-4">
@@ -216,12 +223,12 @@ if (defined("CIS4")) {
<button id="show-recommended" class="btn btn-outline-secondary btn-clearfilter" type="button"
data-bs-toggle="tooltip" data-bs-placement="left" data-bs-html="true"
title="<?php echo $this->p->t('ui', 'nurEmpfohleneAnzeigen'); ?>"><i
class='fa fa-thumbs-o-up'></i>
class='fa-regular fa-thumbs-up'></i>
</button>
<button id="show-not-recommended" class="btn btn-outline-secondary btn-clearfilter"
type="button" data-bs-toggle="tooltip" data-bs-placement="left" data-bs-html="true"
title="<?php echo $this->p->t('ui', 'nurNichtEmpfohleneAnzeigen'); ?>"><i
class='fa fa-thumbs-o-down'></i>
class='fa-regular fa-thumbs-down'></i>
</button>
<button id="show-approved" class="btn btn-outline-secondary btn-clearfilter" type="button"
data-bs-toggle="tooltip" data-bs-placement="left" data-bs-html="true"
@@ -2,6 +2,9 @@
$STUDIENSEMESTER = $studiensemester_selected;
$LEKTOR_UID = getAuthUID();
$LANGUAGE_INDEX = getUserLanguage() == 'German' ? '1' : '2';
// English names only if the Studiengang or LV has one
$STG_BEZEICHNUNG = getUserLanguage() == 'German' ? 'stg.bezeichnung' : 'COALESCE(NULLIF(stg.english, \'\'), stg.bezeichnung)';
$LV_BEZEICHNUNG = getUserLanguage() == 'German' ? 'lv.bezeichnung' : 'COALESCE(NULLIF(lv.bezeichnung_english, \'\'), lv.bezeichnung)';
$query = '
WITH anrechnungen AS
@@ -13,11 +16,11 @@ $query = '
anrechnung.dms_id,
anrechnung.studiensemester_kurzbz,
stg.studiengang_kz,
stg.bezeichnung AS "stg_bezeichnung",
lv.bezeichnung AS "lv_bezeichnung",
' . $STG_BEZEICHNUNG . ' AS "stg_bezeichnung",
' . $LV_BEZEICHNUNG . ' AS "lv_bezeichnung",
lv.ects,
(person.nachname || \' \' || person.vorname) AS "student",
begruendung.bezeichnung AS "begruendung",
COALESCE(begruendung.bezeichnung_mehrsprachig[' . $LANGUAGE_INDEX . '], begruendung.bezeichnung) AS "begruendung",
dmsversion.name AS "dokument_bezeichnung",
anrechnung.anmerkung_student,
(SELECT COALESCE(
@@ -129,7 +132,7 @@ $filterWidgetArray = array(
'requiredPermissions' => 'lehre/anrechnung_empfehlen',
'datasetRepresentation' => 'tabulator',
'columnsAliases' => array(
'Empfehlungsberechtigt',
$this->p->t('anrechnung', 'empfehlungsberechtigt'),
'anrechnung_id',
'lehrveranstaltung_id',
'begruendung_id',
@@ -138,7 +141,7 @@ $filterWidgetArray = array(
'studiengang_kz',
ucfirst($this->p->t('lehre', 'studiengang')),
ucfirst($this->p->t('lehre', 'lehrveranstaltung')),
'ECTS',
$this->p->t('lehre', 'ects'),
ucfirst($this->p->t('person', 'studentIn')),
ucfirst($this->p->t('global', 'begruendung')),
ucfirst($this->p->t('anrechnung', 'nachweisdokumente')),
@@ -147,7 +150,7 @@ $filterWidgetArray = array(
ucfirst($this->p->t('anrechnung', 'antragdatum')),
ucfirst($this->p->t('anrechnung', 'empfehlung')),
'status_kurzbz',
'Status'
$this->p->t('global', 'status')
),
'datasetRepOptions' => '{
height: func_height(this),
+3
View File
@@ -313,6 +313,9 @@ class TableWidget extends Widget
// If session is NOT empty -> a table was already loaded
if ($session != null)
{
// Aliases from the current request, otherwise the headers keep the language of the first load
$this->tablewidgetlib->setSessionElement(TableWidgetLib::SESSION_COLUMNS_ALIASES, $this->_columnsAliases);
// Get SESSION_DATASET_RELOAD from the session
$sessionReloadDataset = $this->tablewidgetlib->getSessionElement(TableWidgetLib::SESSION_DATASET_RELOAD);
-28
View File
@@ -83,40 +83,12 @@ if (! $rechte->isberechtigt('basis/dms', null, 's', null))
return confirm('Möchten Sie das File wirklich löschen?');
}
/*
* CIS4 bridge, ticket 78489.
*
* The TinyMCE 5 editor of the new CMS admin cannot receive a selection through the
* TinyMCE 3 API below, so this window posts the URL back to its opener instead.
*
* message: { typ: 'fhc-dms-selection', url: 'dms.php?id=<dms_id>' }
* target: window.opener, origin checked on both sides
*
* The path is chosen by window.name, which the opener sets and which survives the
* internal navigation of this browser.
*
* Receiving side: public/js/components/Cms/Form/FieldWysiwyg.js
*/
var FileBrowserDialog=
{
init: function(){
},
mySubmit : function (id) {
var URL = "dms.php?id="+id;
// CIS4 path: see the comment block above this function. Do not test for
// tinyMCEPopup, this page loads tiny_mce_popup.js itself and always defines it.
if (window.name === 'DMS_CIS4' && window.opener && !window.opener.closed) {
window.opener.postMessage({ typ: 'fhc-dms-selection', url: URL }, window.location.origin);
window.close();
return;
}
if (typeof tinyMCEPopup === 'undefined') {
alert('The calling window is no longer available.');
return;
}
var win = tinyMCEPopup.getWindowArg("window");
// insert information now
+17 -4
View File
@@ -840,11 +840,8 @@ html {
color: var(--fhc-light);
}
/* the bootstrap opacity 0.5 reaches 2.9:1 on the blue header, 0.75 reaches 4.5:1 (WCAG 1.4.11) */
#cis-main .modal-header .btn-close{
filter: invert(1);
--bs-btn-close-opacity: 0.75;
--bs-btn-close-hover-opacity: 1;
}
#cis-main .modal-footer {
@@ -856,4 +853,20 @@ html {
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
}
.cis-main-menu {
max-width: 100vw;
}
.cis-main-menu ul {
list-style: none;
margin: 0;
padding: 0;
}
.cis-main-menu ul li {
margin: 0;
padding: 0;
width: 100%;
}
-196
View File
@@ -1,196 +0,0 @@
/* Contentcomponents in CIS4 content.
*
* A contentcomponent renders inside its marker div, below #fhc-cms-content. The
* components themselves carry Bootstrap utility classes only, so without this file they
* read as unstyled Bootstrap.
*
* The class names are the contract with the components. A root class follows the pattern
* fhc-contentcomponent-<name> and is set in the template of the component.
*
* Colours come from the theme. public/css/theme/default.css declares the semantic
* variables under :root.light and :root.dark, never on bare :root, so every var() below
* carries a fallback. The fallback repeats the light value.
*
* Two themes are covered: light and dark. purple takes the light values.
*/
:root {
--fhc-cc-muted: #6c757d;
--fhc-cc-accent: var(--fhc-primary, #006095);
--fhc-cc-surface: var(--fhc-secondary, #f6f6f6);
--fhc-cc-hover: var(--fhc-tertiary, #e9ecef);
--fhc-cc-border: var(--fhc-border, #dee2e6);
--fhc-cc-radius: 0.3rem;
}
/* Two values need a step of their own. #006095 and #6c757d both fall under 3:1 on the
dark background, which is too little even for secondary text and for an icon. */
:root.dark {
--fhc-cc-muted: var(--fhc-white-90, #aeb3b8);
--fhc-cc-accent: var(--fhc-blue-40, #61aed8);
}
/* --------------------------------------------------------------------------------- */
/* The marker */
/* --------------------------------------------------------------------------------- */
/* The marker stays in the DOM and wraps the component. scan.js empties it, Teleport
renders the component root into it. It is therefore the place for the outer spacing. */
.fhc-contentcomponent {
display: block;
margin: 1.25rem 0;
}
.fhc-contentcomponent:first-child {
margin-top: 0;
}
.fhc-contentcomponent:last-child {
margin-bottom: 0;
}
.fhc-contentcomponent .alert {
margin-bottom: 0;
}
/* --------------------------------------------------------------------------------- */
/* Person block */
/* --------------------------------------------------------------------------------- */
/* PersonBlock.js. It carries person-block and every row of oe-personen. */
.fhc-contentcomponent-person {
/* flow-root contains the floated photo. It spares the clearfix element, which the
component does not render. */
display: flow-root;
padding: 0.75rem 1rem;
border: 1px solid var(--fhc-cc-border);
border-radius: var(--fhc-cc-radius);
background-color: var(--fhc-cc-surface);
/* The block carries mb-3. The gap of the two lists below owns the spacing instead,
so the last block does not push the content under it. Bootstrap writes the utility
with !important, so this needs it as well. */
margin-bottom: 0 !important;
}
/* The photo carries its width and its object-fit as an inline style. No rule here can
beat that, so only the placement is set. */
.fhc-contentcomponent-person > img {
float: left;
margin-right: 1rem;
border-radius: var(--fhc-cc-radius);
}
/* The name. It is the first div, with or without a photo above it. */
.fhc-contentcomponent-person > div:first-of-type {
font-size: 1.05em;
font-weight: 600;
line-height: 1.4;
}
/* Function, phone, room and mail. The mail and phone links keep the link colour. */
.fhc-contentcomponent-person > div:not(:first-of-type) {
font-size: 0.95em;
color: var(--fhc-cc-muted);
}
/* --------------------------------------------------------------------------------- */
/* oe-personen, person-block */
/* --------------------------------------------------------------------------------- */
.fhc-contentcomponent-oepersonen,
.fhc-contentcomponent-personkontakt {
display: grid;
gap: 0.75rem;
}
/* --------------------------------------------------------------------------------- */
/* dms-liste, dms-dokumente */
/* --------------------------------------------------------------------------------- */
/* Both render DokumentListe.js: ul.list-unstyled with one li per document. */
.fhc-contentcomponent-dmsliste ul,
.fhc-contentcomponent-dmsdokumente ul {
border: 1px solid var(--fhc-cc-border);
border-radius: var(--fhc-cc-radius);
/* Keeps the first and the last row inside the rounded corners. */
overflow: hidden;
}
.fhc-contentcomponent-dmsliste li,
.fhc-contentcomponent-dmsdokumente li {
padding: 0.5rem 0.75rem;
border-top: 1px solid var(--fhc-cc-border);
/* The row carries mb-2. The line above separates the rows instead. */
margin-bottom: 0 !important;
}
.fhc-contentcomponent-dmsliste li:first-child,
.fhc-contentcomponent-dmsdokumente li:first-child {
border-top: 0;
}
.fhc-contentcomponent-dmsliste li:hover,
.fhc-contentcomponent-dmsdokumente li:hover {
background-color: var(--fhc-cc-surface);
}
/* The file type icon sits inside the link and would take the link colour. A fixed width
lines the names up over every row, whatever the icon is. */
.fhc-contentcomponent-dmsliste li i,
.fhc-contentcomponent-dmsdokumente li i {
width: 1.25rem;
margin-right: 0.25rem;
text-align: center;
color: var(--fhc-cc-accent);
}
.fhc-contentcomponent-dmsliste li a,
.fhc-contentcomponent-dmsdokumente li a {
text-decoration: none;
}
.fhc-contentcomponent-dmsliste li a:hover,
.fhc-contentcomponent-dmsliste li a:focus,
.fhc-contentcomponent-dmsdokumente li a:hover,
.fhc-contentcomponent-dmsdokumente li a:focus {
text-decoration: underline;
}
/* The description is the only block element in the row. It starts below the icon, so it
takes the width of the icon as an indent and lines up with the document name. */
.fhc-contentcomponent-dmsliste li > div,
.fhc-contentcomponent-dmsdokumente li > div {
padding-left: 1.5rem;
}
/* --------------------------------------------------------------------------------- */
/* contentchild-menu */
/* --------------------------------------------------------------------------------- */
/* The successor of the legacy menubox. The accent rule replaces its grey background as
the mark that says: this is a menu, not running text. */
.fhc-contentcomponent-contentchildmenu ul {
padding: 0.5rem;
border-left: 3px solid var(--fhc-cc-accent);
border-radius: 0 var(--fhc-cc-radius) var(--fhc-cc-radius) 0;
background-color: var(--fhc-cc-surface);
}
.fhc-contentcomponent-contentchildmenu li {
/* The entry carries mb-1. The link padding owns the spacing instead. */
margin-bottom: 0 !important;
}
/* A block link makes the whole row the target, not the length of the title. */
.fhc-contentcomponent-contentchildmenu a {
display: block;
padding: 0.35rem 0.5rem;
border-radius: var(--fhc-cc-radius);
text-decoration: none;
}
.fhc-contentcomponent-contentchildmenu a:hover,
.fhc-contentcomponent-contentchildmenu a:focus {
background-color: var(--fhc-cc-hover);
text-decoration: underline;
}
-97
View File
@@ -1,97 +0,0 @@
/* Full-height shell. Without a height chain down to .cms-admin the page itself scrolls
and the tabs leave the screen while paging through the tree. */
html,
body {
height: 100%;
}
body {
display: flex;
flex-direction: column;
overflow: hidden;
}
#main {
flex: 1 1 auto;
min-height: 0;
}
.cms-admin {
min-height: 0;
}
/* The split component only caps its height with max-height, so give it a definite one. */
.cms-admin .horizontalsplit-container {
height: 100%;
}
.cms-admin .horizontalsplitted {
min-width: 0;
overscroll-behavior: contain;
}
.cms-admin-main {
min-width: 0;
overflow: hidden;
}
.cms-admin-tab-content {
min-height: 0;
}
/* Anchor for the overlay that blocks the view during a content switch. */
.cms-admin {
position: relative;
}
.cms-admin-overlay {
position: absolute;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.6);
cursor: wait;
}
/* content locker to groups get a gold padlock - resembles legacy cms */
.cms-tree-lock {
color: #f0c000;
text-shadow: 0 0 1px #8a6000;
}
/* A frame swallows the mouse events that reach it, so dragging the split handle over the
preview ends the drag. Both frames go through for the length of the drag, so the events
reach the page underneath. Only a drag sets this class, so the preview stays clickable
the rest of the time. */
.cms-preview-dragging iframe {
pointer-events: none;
}
/* The path down to a search hit, and the subtree under it. Both are context, so they step
back and let the hit itself carry the normal weight. */
.cms-tree-ancestor {
font-size: 0.8em;
color: #6c757d;
line-height: 1.4;
}
/* The path is context and keeps the grey, but it leads somewhere, so it underlines on
hover like any other link. */
.cms-tree-ancestor a {
color: inherit;
text-decoration: none;
}
.cms-tree-ancestor a:hover,
.cms-tree-ancestor a:focus {
color: var(--bs-link-color, #0d6efd);
text-decoration: underline;
}
.cms-tree-context > div > a,
.cms-tree-context > div > span {
font-size: 0.85em;
color: #6c757d;
}
+1 -22
View File
@@ -121,9 +121,8 @@
background-color: #000;
}
/* contrast: #777 reaches 4.48:1 on the odd and 4:1 on the even rows (#f2f2f2), #6c6c6c 5.3:1 and 4.7:1 */
.tabulator-row.tabulator-unselectable:not(.tabulator-calcs) {
color: #6c6c6c !important;
color: #777 !important;
}
.tabulator-cell .btn {
@@ -157,23 +156,3 @@
color: #fff;
background: #1d68cd;
}
/* contrast (WCAG 1.4.11): tabulator_simple draws the inactive sort arrow in #bbb (1.9:1) and the
* placeholder in #ccc (1.6:1). #8f8f8f (3.2:1) is close to the grey of the active arrow, so the
* active arrow gets the primary color (6.8:1) */
.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-sorter .tabulator-arrow,
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="none"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: #8f8f8f;
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--fhc-primary, #006095);
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-top-color: var(--fhc-primary, #006095);
}
.tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
color: #6c757d;
}
-81
View File
@@ -1,81 +0,0 @@
/* the panel of js/directives/inViewTooltip.js. place() sets its place, max width and max height
inside the viewport, so neither a scrolling container nor a modal body clips it */
.fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary, #0d6efd);
position: fixed;
z-index: 1080;
display: flex;
flex-direction: column;
width: max-content;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
background-color: var(--bs-body-bg, #fff);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
color: var(--bs-body-color, #212529);
font-size: 0.875rem;
font-weight: normal;
line-height: 1.5;
text-align: left;
}
/* the dark primary reaches 2:1 on the dark panel, the highlight 3.9:1 */
html.dark .fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary-highlight, #6ea8fe);
}
/* the body scrolls, so the progress border stays on the panel edge */
.fhc-in-view-tooltip-body {
min-height: 0;
overflow-y: auto;
padding: 0.75rem 1rem;
white-space: pre-line; /* the text keeps its line breaks */
}
/* the hover timer of a sticky panel: after a pause the border fills up clockwise, then the panel sticks.
The 2 px stroke covers the 1 px border and 1 px inside it */
.fhc-in-view-tooltip-progress {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.fhc-in-view-tooltip-progress rect {
fill: none;
stroke: var(--fhc-in-view-tooltip-progress);
stroke-width: 2;
stroke-dasharray: var(--fhc-in-view-tooltip-perimeter);
stroke-dashoffset: var(--fhc-in-view-tooltip-perimeter);
}
.fhc-in-view-tooltip--holding .fhc-in-view-tooltip-progress rect {
animation: fhc-in-view-tooltip-hold var(--fhc-in-view-tooltip-hold-fill) ease-in var(--fhc-in-view-tooltip-hold-delay) forwards;
}
/* a click during the hover timer fills the rest of the border quickly */
.fhc-in-view-tooltip--pinned .fhc-in-view-tooltip-progress rect {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.15s linear;
}
@keyframes fhc-in-view-tooltip-hold {
to {
stroke-dashoffset: 0;
}
}
/* the ? icon of components/InViewHelp.js takes the color of its context, e.g. white in the blue
modal header. A class like text-muted overrides it */
.fhc-in-view-help-btn,
.fhc-in-view-help-btn:hover,
.fhc-in-view-help-btn:focus-visible,
.btn.fhc-in-view-help-btn:active {
color: inherit;
}
.fhc-in-view-help-btn {
line-height: 1;
text-decoration: none;
}
+3 -154
View File
@@ -188,9 +188,6 @@
--fhc-light: var(--fhc-white-50);
--fhc-cis-menu-lvl-1-color: var(--fhc-white-50);
/* input borders, 3:1 against the background and the tinted card headers (#f8f8f9) */
--fhc-input-border: #868e96;
}
:root.dark {
@@ -236,8 +233,6 @@
--fhc-dark: var(--fhc-black-50);
--fhc-light: var(--fhc-white-70);
/* input borders, 3:1 against the background */
--fhc-input-border: #7c8791;
}
:root.purple {
@@ -318,13 +313,12 @@ html.dark .border-primary {
border-color: var(--fhc-primary) !important;
}
/* fhc danger: --fhc-danger is a light red for text and borders on the dark background. White text
* on it reaches 1.2:1, the backgrounds use the dark red (6.5:1) */
/* fhc danger*/
html.dark .bg-danger,
html.dark .btn-danger,
html.dark .btn-outline-danger {
background-color: var(--fhc-red-60) !important;
border-color: var(--fhc-red-60) !important;
background-color: var(--fhc-danger) !important;
border-color: var(--fhc-danger) !important;
color: white;
}
@@ -333,25 +327,6 @@ html.dark .btn-danger:hover {
filter: brightness(90%);
}
/* the dark primevue theme draws p-button-danger (e.g. FhcAlert.confirmDelete) light pink with black text,
* use the dark red of .btn-danger */
html.dark .p-button.p-button-danger {
color: white;
background: var(--fhc-red-60);
border-color: var(--fhc-red-60);
}
html.dark .p-button.p-button-danger:enabled:hover,
html.dark .p-button.p-button-danger:enabled:active {
color: white;
background: var(--fhc-red-70);
border-color: var(--fhc-red-70);
}
html.dark .p-button.p-button-danger:enabled:focus {
box-shadow: 0 0 0 0.2rem rgba(225, 110, 120, 0.5);
}
html.dark .text-danger {
color: var(--fhc-danger) !important;
}
@@ -360,55 +335,6 @@ html.dark .border-danger {
border-color: var(--fhc-danger) !important;
}
/* bootstrap draws btn-outline-secondary in #6c757d: 4.3:1 on the light dialog footer (#f6f6f6).
* --fhc-gray-30 reaches 5.4:1 there, the white hover text 5.9:1 */
html.light .btn-outline-secondary {
--bs-btn-color: var(--fhc-gray-30);
--bs-btn-border-color: var(--fhc-gray-30);
--bs-btn-hover-bg: var(--fhc-gray-30);
--bs-btn-hover-border-color: var(--fhc-gray-30);
--bs-btn-active-bg: var(--fhc-gray-30);
--bs-btn-active-border-color: var(--fhc-gray-30);
}
/* bootstrap keeps #6c757d for btn-outline-secondary in the dark theme: 2.4:1 on the dark tables and dialogs */
html.dark .btn-outline-secondary {
--bs-btn-color: var(--fhc-white-90);
--bs-btn-border-color: var(--fhc-white-90);
--bs-btn-hover-color: var(--fhc-black-30);
--bs-btn-hover-bg: var(--fhc-white-90);
--bs-btn-hover-border-color: var(--fhc-white-90);
--bs-btn-active-color: var(--fhc-black-30);
--bs-btn-active-bg: var(--fhc-white-90);
--bs-btn-active-border-color: var(--fhc-white-90);
}
/* the primevue multiselect renders the check icon also in unchecked boxes and hides it by color:
* white on white in the light theme, but #151515 on #20262e in the dark theme */
html.dark .p-checkbox .p-checkbox-box:not(.p-highlight) .p-checkbox-icon {
color: transparent;
}
/* FhcAlert.confirm passes bootstrap classes to the primevue buttons. The primevue theme loads after
* bootstrap, its .p-button colors replace the .btn colors: every button blue */
.p-button.btn {
color: var(--bs-btn-color);
background: var(--bs-btn-bg);
border-color: var(--bs-btn-border-color);
}
.p-button.btn:enabled:hover {
color: var(--bs-btn-hover-color);
background: var(--bs-btn-hover-bg);
border-color: var(--bs-btn-hover-border-color);
}
.p-button.btn:enabled:active {
color: var(--bs-btn-active-color);
background: var(--bs-btn-active-bg);
border-color: var(--bs-btn-active-border-color);
}
html.dark .tabulator,
html.dark .tabulator .tabulator-header,
@@ -429,52 +355,6 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover {
background-color: var(--fhc-tertiary) !important;
}
/* the active sort arrow of tabulator_simple (#666) reaches 2:1 on the dark header */
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--fhc-text);
}
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-top-color: var(--fhc-text);
}
@media (hover: hover) and (pointer: fine) {
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable .tabulator-col-content .tabulator-col-sorter.tabulator-col-sorter-element .tabulator-arrow:hover {
border-bottom-color: var(--fhc-text);
border-top-color: var(--fhc-text);
}
}
/* the group toggle arrow of tabulator_simple (#666) reaches 2:1 on the dark rows */
html.dark .tabulator .tabulator-row.tabulator-group:not(.tabulator-group-visible) .tabulator-arrow {
border-left-color: var(--fhc-text);
}
html.dark .tabulator .tabulator-row.tabulator-group.tabulator-group-visible .tabulator-arrow {
border-top-color: var(--fhc-text);
}
/* the footer stays white otherwise: the page buttons get the white button text of the dark color scheme */
html.dark .tabulator .tabulator-footer {
background-color: var(--fhc-tertiary);
border-top-color: var(--fhc-border);
color: var(--fhc-text);
}
html.dark .tabulator .tabulator-footer .tabulator-page {
background: transparent;
border-color: var(--fhc-white-90);
color: var(--fhc-text);
}
html.dark .tabulator .tabulator-footer .tabulator-page.active {
color: var(--fhc-red-30);
}
html.dark .tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
color: var(--fhc-white-90);
}
.p-paginator{
background-color: var(--fhc-background) !important;
}
@@ -496,35 +376,4 @@ html.dark .tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-pl
.p-link:focus {
box-shadow: 0 0 0 0.2rem rgba(var(--fhc-primary), 0.5) !important;
}
/* input borders: 3:1 against the background (WCAG 1.4.11), the bootstrap, primevue and datepicker defaults
* reach 1.3 - 1.9:1. Focus and checked states of the libraries stay */
.form-control,
.form-select,
.form-check-input,
.p-inputtext,
.p-inputtext:enabled:hover:not(:focus),
.p-dropdown,
.p-dropdown:not(.p-disabled):hover:not(.p-focus),
.p-multiselect,
.p-multiselect:not(.p-disabled):hover:not(.p-focus),
.p-checkbox .p-checkbox-box,
.p-radiobutton .p-radiobutton-box {
border-color: var(--fhc-input-border, #868e96);
}
/* the datepicker css loads after the theme, html raises the specificity */
html .dp__theme_light,
html .dp__theme_dark {
--dp-border-color: var(--fhc-input-border, #868e96);
--dp-border-color-hover: var(--fhc-border-highlight, #495057);
}
/* the light datepicker (also in the dark cis4 theme) draws the days of other months in #c0c4cc (1.8:1),
* the icons in #959595 (3:1) and the selected day in #f8f5f5 on #1976d2 (4.2:1) */
html .dp__theme_light {
--dp-secondary-color: #6c6c6c;
--dp-icon-color: #6c757d;
--dp-primary-text-color: #fff;
}
+1 -1
View File
@@ -585,7 +585,7 @@ var FHC_TableWidget = {
let defaultPersistence = {
sort: true,
columns: true,
columns: ["width", "visible"],
filter: false,
headerFilter: false,
group: false,
+2 -51
View File
@@ -16,8 +16,7 @@
*/
export default {
// preview flag to suppress click log entry. The CMS admin preview passes it.
content(content_id, version=null, sprache=null, sichtbar=null, preview=false) {
content(content_id, version=null, sprache=null, sichtbar=null) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/content',
@@ -25,8 +24,7 @@ export default {
content_id,
...(version ? { version } : {}),
...(sprache ? { sprache } : {}),
...(sichtbar ? { sichtbar } : {}),
...(preview ? { preview: 1 } : {})
...(sichtbar ? { sichtbar } : {})
}
};
},
@@ -56,53 +54,6 @@ export default {
url: '/api/frontend/v1/Cms/getNewsRowCount'
};
},
// Used by the CMS content component "oe-personen".
// tiefe 0 asks about the one unit, higher takes that many levels of sub units with it.
getOePersonen(oe_kurzbz, foto, tiefe) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getOePersonen',
params: {
oe_kurzbz,
...(foto ? { foto: 1 } : {}),
...(tiefe ? { tiefe } : {})
}
};
},
// Used by the CMS content component "person-block".
getPerson(uid, foto) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getPerson',
params: { uid, ...(foto ? { foto: 1 } : {}) }
};
},
// Used by the CMS content component "dms-dokumente".
getDmsDokumente(dms_ids) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getDmsDokumente',
params: { dms_ids }
};
},
// Used by the CMS content component "dms-liste".
// tiefe 0 asks about the one category, higher takes that many levels of sub categories
// with it. The server checks the entitlement of every category it reaches.
getDmsKategorie(kategorie_kurzbz, tiefe) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getDmsKategorie',
params: { kategorie_kurzbz, ...(tiefe ? { tiefe } : {}) }
};
},
// Used by the CMS content component "contentchild-menu".
getContentChilds(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/Cms/getContentChilds',
params: { content_id, ...(sprache ? { sprache } : {}) }
};
},
getNewsExtra() {
// TODO(chris): seems to be called from nowhere?
return {
-296
View File
@@ -1,296 +0,0 @@
export default {
// --- CmsAdmin ---
getTree(menu, filter) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getTree',
params: { menu, ...(filter ? { filter } : {}) }
};
},
getSubtree(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getSubtree',
params: { content_id }
};
},
getContent(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getContent',
params: { content_id }
};
},
getContentsprache(content_id, sprache, version) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getContentsprache',
params: { content_id, sprache, version }
};
},
// Feeds the tree filter: child count, insertamum, updateamum and the own-edit flag.
getTreeMeta() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getTreeMeta'
};
},
// Where each content sits. The tree asks for the whole visible list at once, so the
// hierarchy toggle costs one request and not one per node.
getAncestors(content_ids) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getAncestors',
params: { content_ids: content_ids.join(',') }
};
},
// Views of one content. One counting query, so the tab loads it right away.
// The server gates both click endpoints on the config flag and on the admin right,
// so a caller without both gets an error instead of numbers.
getClickCount(content_id, months) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getClickCount',
params: { content_id, months }
};
},
// The ranking over the whole log. Takes seconds, so the tab asks for it on request.
getClickRanking(months, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getClickRanking',
params: { months, sprache }
};
},
getTemplates() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getTemplates'
};
},
getOrganisationseinheiten() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getOrganisationseinheiten'
};
},
getSprachen() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getSprachen'
};
},
// Fills the DMS category field of the contentcomponent insert dialog.
getDmsKategorien() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getDmsKategorien'
};
},
// Fills the person field of the contentcomponent insert dialog.
getMitarbeiter() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getMitarbeiter'
};
},
// Fills the document field of the contentcomponent insert dialog.
getDmsKategorieDokumente(kategorie_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getDmsKategorieDokumente',
params: { kategorie_kurzbz }
};
},
getUsage(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdmin/getUsage',
params: { content_id }
};
},
postContent(parent_content_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/postContent',
params: { parent_content_id }
};
},
postTranslation(content_id, sprache, version, target_sprache) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/postTranslation',
params: { content_id, sprache, version, target_sprache }
};
},
postVersion(content_id, sprache) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/postVersion',
params: { content_id, sprache }
};
},
putProperties(content_id, sprache, version, template_kurzbz, oe_kurzbz, aktiv, menu_open, beschreibung, titel, sichtbar) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/putProperties',
params: { content_id, sprache, version, template_kurzbz, oe_kurzbz, aktiv, menu_open, beschreibung, titel, sichtbar }
};
},
deleteContent(content_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/deleteContent',
params: { content_id }
};
},
deleteContentsprache(content_id, sprache, version) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdmin/deleteContentsprache',
params: { content_id, sprache, version }
};
},
// --- CmsAdminInhalt ---
getFormSchema(template_kurzbz) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getFormSchema',
params: { template_kurzbz }
};
},
getFormData(content_id, sprache, version) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getFormData',
params: { content_id, sprache, version }
};
},
getLock(content_id, sprache, version) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getLock',
params: { content_id, sprache, version }
};
},
getVersions(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminInhalt/getVersions',
params: { content_id, sprache }
};
},
putFormData(content_id, sprache, version, values) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/putFormData',
params: { content_id, sprache, version, values }
};
},
postLock(contentsprache_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/postLock',
params: { contentsprache_id }
};
},
deleteLock(contentsprache_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/deleteLock',
params: { contentsprache_id }
};
},
deleteLockForced(contentsprache_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminInhalt/deleteLockForced',
params: { contentsprache_id }
};
},
// --- CmsAdminStruktur ---
getGruppen(content_id) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getGruppen',
params: { content_id }
};
},
getAllGruppen() {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getAllGruppen'
};
},
getChilds(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getChilds',
params: { content_id, sprache }
};
},
// The hierarchy tab lists them above the children. A content can have several parents.
getParents(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getParents',
params: { content_id, sprache }
};
},
getPossibleChilds(content_id, sprache) {
return {
method: 'get',
url: '/api/frontend/v1/CmsAdminStruktur/getPossibleChilds',
params: { content_id, sprache }
};
},
postGruppe(content_id, gruppe_kurzbz) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/postGruppe',
params: { content_id, gruppe_kurzbz }
};
},
deleteGruppe(content_id, gruppe_kurzbz) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/deleteGruppe',
params: { content_id, gruppe_kurzbz }
};
},
postChild(content_id, child_content_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/postChild',
params: { content_id, child_content_id }
};
},
deleteChild(contentchild_id) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/deleteChild',
params: { contentchild_id }
};
},
putChildSort(contentchild_id, direction) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/putChildSort',
params: { contentchild_id, direction }
};
},
// Writes the whole order after a drag and drop. The ids carry the wanted order.
putChildOrder(content_id, contentchild_ids) {
return {
method: 'post',
url: '/api/frontend/v1/CmsAdminStruktur/putChildOrder',
params: { content_id, contentchild_ids }
};
}
};
+1 -6
View File
@@ -150,8 +150,7 @@ const router = VueRouter.createRouter({
}
},
{
// optional version for cms admin preview
path: `/CisVue/Cms/Content/:content_id/:version?`,
path: `/CisVue/Cms/Content/:content_id`,
name: 'Content',
component: CmsContent,
props: true
@@ -351,9 +350,5 @@ app.directive('contrast', contrast);
app.mount('#fhccontent');
router.afterEach((to, from, failure) => {
// dont log cms admin preview
if (to.query.preview === '1')
return;
app.config.globalProperties.$api.call(ApiRouteInfo.info('cis4', to.fullPath));
});
-44
View File
@@ -1,44 +0,0 @@
import CmsAdmin from "../components/Cms/CmsAdmin.js";
import PluginsPhrasen from "../plugins/Phrasen.js";
const ciPath = FHC_JS_DATA_STORAGE_OBJECT.app_root.replace(/(https:|)(^|\/\/)(.*?\/)/g, '')
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router;
const router = VueRouter.createRouter({
history: VueRouter.createWebHistory('/' + ciPath),
routes: [
{
name: 'index',
path: '/cms',
component: CmsAdmin,
children: [
{
// Only content_id is required. CmsAdmin fills the missing parts
// from the content itself and rewrites the address.
name: 'content',
path: 'content/:content_id/:sprache?/:version?/:tab?',
component: CmsAdmin
}
]
},
{ path: '/:pathMatch(.*)*', redirect: { name: 'index' } }
]
});
FhcApps.router.makeExtendable(router);
const app = Vue.createApp({
name: 'CmsApp'
});
FhcApps.makeExtendable(app);
app
.use(router)
.use(primevue.config.default, {
zIndex: {
overlay: 1100
}
})
.use(PluginsPhrasen)
.mount('#main');
+2 -2
View File
@@ -73,7 +73,6 @@ export default {
}
},
computed: {
// opacity 0.5 reaches 2.4:1 on the blue cis4 modal header, 0.75 reaches 3.6:1 (WCAG 1.4.11)
getExpandButtonStyles() {
const hovered = this.expandBtnHovered;
const focused = this.expandBtnFocused;
@@ -86,11 +85,12 @@ export default {
border: 0;
background: transparent;
font-size: 1em;
opacity: 0.5;
color: inherit;
cursor: pointer;
line-height: 1;
transition: opacity 0.15s ease;
opacity: ${focused || hovered ? '1' : '0.75'};
opacity: ${focused ? '1' : hovered ? '0.75' : '0.5'};
outline: ${focused ? '1px solid currentColor' : 'none'};
outline-offset: 2px;`
}
+2 -10
View File
@@ -38,9 +38,7 @@ export default {
methods: {
fetchContent(){
this.$api
.call(ApiCms.content(
this.content_id_internal, this.version, this.sprache, this.sichtbar, this.preview
))
.call(ApiCms.content(this.content_id_internal, this.version, this.sprache, this.sichtbar))
.then(res => {
this.$nextTick(function() {
this.content = res.data.content;
@@ -59,14 +57,8 @@ export default {
}
},
computed: {
// The viewer language, unless the address names one. The CMS admin previews a
// chosen language, which is not always the language of the editor interface.
sprache(){
return this.$route?.query?.sprache || this.$p.user_language.value;
},
// A preview renders the content without counting a view.
preview(){
return this.$route?.query?.preview === '1';
return this.$p.user_language.value;
},
computeContentType: function () {
switch (this.content_type) {
@@ -1,5 +1,4 @@
import { replaceRelativeLegacyLink } from "../../../../helpers/LegacyLinkReplaceHelper.js"
import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js";
export default {
name: "GeneralComponent",
props:{
@@ -7,13 +6,7 @@ export default {
type:String,
required:true,
},
content_id:{
type: [Number, String],
},
},
components: {
ContentcomponentHost
},
methods: {
sanitizeLegacyTables(table) {
@@ -131,7 +124,7 @@ export default {
template: /*html*/ `
<!-- div that contains the content -->
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
<contentcomponent-host :content="content" :content-id="content_id"></contentcomponent-host>
<div v-html="content" ></div>
</div></div></div>
<p v-else>Content was not found</p>
`,
@@ -134,7 +134,6 @@ export default {
template: /*html*/ `
<!-- div that contains the content -->
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
<!-- No contentcomponent-host on purpose: news contents stay static by convention. -->
<div v-html="content" ></div>
</div></div></div>
<p v-else>Content was not found</p>
@@ -1,4 +1,4 @@
import ContentcomponentHost from "../../../Contentcomponents/ContentcomponentHost.js";
export default {
name: "RaumComponent",
data() {
@@ -15,9 +15,6 @@ export default {
type: [Number, String],
}
},
components: {
ContentcomponentHost
},
methods: {
sanitizeLegacyTables(table) {
@@ -138,7 +135,7 @@ export default {
<!-- div that contains the content -->
<!-- TODO: test with more img content from cms-->
<div v-if="imgContent"><img v-bind="imgContent"/></div>
<contentcomponent-host v-else-if="content" :content="content" :content-id="content_id"></contentcomponent-host>
<div v-html="content" v-else-if="content" ></div>
<p v-else>Content was not found</p>
`,
};
-1
View File
@@ -117,7 +117,6 @@ export default {
></pagination>
<div class="container-fluid mt-4">
<div class="row">
<!-- No contentcomponent-host on purpose: news stays static by convention. -->
<div class="col" v-html="content">
</div>
<div class="col-auto">
+1 -1
View File
@@ -186,7 +186,7 @@ export default {
</div>
</div>
<nav id="nav-main" class="offcanvas offcanvas-start" tabindex="-1" aria-labelledby="nav-main-btn" data-bs-backdrop="false">
<nav id="nav-main" class="cis-main-menu offcanvas offcanvas-start" tabindex="-1" aria-labelledby="nav-main-btn" data-bs-backdrop="false">
<div id="nav-main-sticky">
<div class="d-flex flex-row h-100">
<div class="offcanvas-body p-0">
+5 -1
View File
@@ -162,6 +162,7 @@ export default {
<div class="btn-group w-100">
<a :target="target"
:href="(entry.menu_open && hasFullLink) ? entry.url : null"
draggable="false"
@click="toggleCollapse"
:class="{
'btn btn-default rounded-0 text-start': true,
@@ -180,12 +181,15 @@ export default {
</div>
<ul ref="children"
class="nav w-100 collapse">
<cis-menu-entry :highestMatchingUrlCount="highestMatchingUrlCount" :activeContent="activeContent" v-for="child in entry.childs" :key="child" :entry="child" :level="level + 1"/>
<li>
<cis-menu-entry :highestMatchingUrlCount="highestMatchingUrlCount" :activeContent="activeContent" v-for="child in entry.childs" :key="child" :entry="child" :level="level + 1"/>
</li>
</ul>
</template>
<a v-else
:href="entry.url"
:target="target"
draggable="false"
:class="{
'btn btn-default rounded-0 w-100 text-start': true,
['btn-level-' + level]: true,
@@ -66,24 +66,6 @@ export default {
return "d-none d-xl-grid";
}
},
// link into the FHC-Core-Anwesenheiten tool
// digi_anw_data is attached per event by the extension's `extendStundenplanData` listener
anwLink() {
const d = this.event && this.event.digi_anw_data;
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
return null;
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/extensions/FHC-Core-Anwesenheiten/';
return base
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
+ `&lvid=${encodeURIComponent(d.lv_id)}`
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
},
anwLinkTitle() {
return this.$p.t('global/anwKontrolleOeffnen');
},
},
template: /*html*/`
<div
@@ -114,21 +96,6 @@ export default {
... +{{ event.lektor.length - 3 }}
</span>
</div>
<a
v-if="anwLink"
:href="anwLink"
target="_blank"
rel="noopener"
class="d-flex align-items-center flex-shrink-0"
style="margin-right: 4px"
:title="anwLinkTitle"
:aria-label="anwLinkTitle"
draggable="false"
@click.stop
@mousedown.stop
>
<i class="fa fa-arrow-up-right-from-square fs-5" aria-hidden="true"></i>
</a>
</div>
`,
}
@@ -49,20 +49,6 @@ export default {
return this.event.ende;
}
return numberPadding(this.event.ende.getHours()) + ":" + numberPadding(this.event.ende.getMinutes());
},
// Deep link into the attendance tool; only present when the anw extension wrote into digi_anw_data
anwLink: function () {
const d = this.event && this.event.digi_anw_data;
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
return null;
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/extensions/FHC-Core-Anwesenheiten/';
return base
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
+ `&lvid=${encodeURIComponent(d.lv_id)}`
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
}
},
watch: {
@@ -174,16 +160,6 @@ export default {
}}</th>
<td>{{event.organisationseinheit}}</td>
</tr>
<tr v-if="anwLink">
<th>{{
$p.t('global','anwKontrolle')?
$p.t('global','anwKontrolle')+':'
:''
}}</th>
<td>
<a :href="anwLink" target="_blank" rel="noopener" :aria-label="$p.t('global','anwKontrolleOeffnen')" :title="$p.t('global','anwKontrolleOeffnen')"><i class="fa fa-arrow-up-right-from-square me-1" style="color:#00649C" aria-hidden="true"></i></a>
</td>
</tr>
</tbody>
</table>
<lv-menu v-if="lvMenu.length && $route.name === 'MyLvPlan'" :containerStyles="['p-0']" :rowStyles="['m-0']" :menu="lvMenu" />
-303
View File
@@ -1,303 +0,0 @@
import ApiCmsAdmin from '../../api/factory/cmsadmin.js';
import ContentTree from './Tree/ContentTree.js';
import ContentHeader from './Header/ContentHeader.js';
import Properties from './Tabs/Properties.js';
import ContentTab from './Tabs/Content.js';
import Permissions from './Tabs/Permissions.js';
import Hierarchy from './Tabs/Hierarchy.js';
import History from './Tabs/History.js';
import Delete from './Tabs/Delete.js';
import Clickstats from './Tabs/Clickstats.js';
import HorizontalSplit from '../horizontalsplit/horizontalsplit.js';
const TAB_COMPONENTS = {
properties: Properties,
content: ContentTab,
permissions: Permissions,
hierarchy: Hierarchy,
history: History,
delete: Delete
};
const TABS = [
{ key: 'properties', phrase: 'cms/tabEigenschaften' },
{ key: 'content', phrase: 'cms/tabInhalt' },
{ key: 'permissions', phrase: 'cms/tabRechte' },
{ key: 'hierarchy', phrase: 'cms/tabHierarchie' },
{ key: 'history', phrase: 'cms/tabHistory' },
{ key: 'delete', phrase: 'cms/tabLoeschen' }
];
// The view renders the clickstats attribute only for an admin, and only with the config
// flag set.
const CLICKSTATS_TAB = { key: 'clickstats', phrase: 'cms/tabKlickstatistik' };
export default {
name: 'CmsAdmin',
components: {
'cms-tree': ContentTree,
'cms-content-header': ContentHeader,
'horizontal-split': HorizontalSplit,
...TAB_COMPONENTS
},
props: {
cmsRoot: String,
authUid: String,
defaultLanguage: { type: String, default: 'German' },
clickstats: { type: Boolean, default: false }
},
data() {
return {
contentId: null,
sprache: null,
version: null,
tab: 'properties',
contentInfo: null,
pending: 0
};
},
computed: {
tabs() {
return this.clickstats ? TABS.concat([CLICKSTATS_TAB]) : TABS;
},
tabComponents() {
return this.clickstats
? { ...TAB_COMPONENTS, clickstats: Clickstats }
: TAB_COMPONENTS;
},
activeTabComponent() {
return this.tabComponents[this.tab] || null;
},
busy() {
return this.pending > 0;
}
},
watch: {
'$route.params': {
handler(params) {
const id = params.content_id ? Number(params.content_id) : null;
const sprache = params.sprache || null;
const version = params.version ? Number(params.version) : null;
// unknown tab fallback
const tab = this.tabComponents[params.tab] ? params.tab : 'properties';
if (id !== this.contentId || sprache !== this.sprache
|| version !== this.version || tab !== this.tab) {
this.contentId = id;
this.sprache = sprache;
this.version = version;
this.tab = tab;
if (id === null) return;
if (this.contentInfo?.content_id !== id) {
this.loadContentInfo().then(() => { this.applyRouteDefaults(); });
} else {
this.applyRouteDefaults();
}
}
},
immediate: true
}
},
methods: {
loadContentInfo() {
if (this.contentId === null) {
this.contentInfo = null;
return Promise.resolve();
}
this.pending++;
return this.$api
.call(ApiCmsAdmin.getContent(this.contentId))
.then(result => {
this.contentInfo = result.data;
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.pending--; });
},
// Language, version and tab are optional in the address. Fill in what the address
// leaves out, and replace a value that the content does not have. Then write the
// complete address back, so a reload and a copied link both work.
applyRouteDefaults() {
if (this.contentId === null || !this.contentInfo) return;
const languages = this.contentInfo.languages || [];
const versions = this.contentInfo.versions || {};
let lang = this.sprache;
if (lang === null || !languages.includes(lang)) {
lang = languages.includes(this.defaultLanguage)
? this.defaultLanguage
: languages[0] || null;
}
const langVersions = (lang && versions[lang]) ? versions[lang] : [];
let ver = this.version;
if (ver === null || !langVersions.includes(ver)) {
ver = langVersions.length ? Math.max(...langVersions) : null;
}
if (lang === this.sprache && ver === this.version) return;
this.sprache = lang;
this.version = ver;
// Replace, or the short address stays in the history and the back button
// walks through both forms of the same page.
this.pushRoute(true);
},
selectContent(content_id) {
if (content_id === null) {
this.contentId = null;
this.sprache = null;
this.version = null;
this.tab = 'properties';
this.contentInfo = null;
this.$router.push({ name: 'index' });
return;
}
// Set id, language and version in one flush, or the tabs query the new content
// with the version of the old one.
this.pending++;
this.$api
.call(ApiCmsAdmin.getContent(content_id))
.then(result => {
this.contentId = content_id;
this.contentInfo = result.data;
this.preselectLanguageVersion();
this.pushRoute();
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.pending--; });
},
selectTab(tab) {
this.tab = tab;
this.pushRoute();
},
selectLanguageVersion(sprache, version) {
this.sprache = sprache;
this.version = version;
this.pushRoute();
},
// Three grades of tree update. Pick the cheapest one that stays correct.
// A full reload drops the expanded branches and refetches the filter meta,
// so keep it for changes that move nodes between branches.
reloadTree() {
if (this.$refs.tree) this.$refs.tree.reload();
},
refreshTreeNode(contentId) {
if (this.$refs.tree) this.$refs.tree.refreshNode(contentId);
},
patchTreeNode(payload) {
if (this.$refs.tree) this.$refs.tree.patchNode(payload.content_id, payload.patch);
},
preselectLanguageVersion() {
if (!this.contentInfo) return;
const languages = this.contentInfo.languages || [];
const versions = this.contentInfo.versions || {};
let lang = languages.includes(this.defaultLanguage)
? this.defaultLanguage
: languages[0] || null;
let ver = null;
if (lang && versions[lang] && versions[lang].length) {
ver = Math.max(...versions[lang]);
}
this.sprache = lang;
this.version = ver;
},
pushRoute(replace = false) {
if (this.contentId === null) {
this.$router.push({ name: 'index' });
return;
}
// The optional parts are positional. Stop at the first missing one, or the
// address carries an empty segment.
const params = { content_id: this.contentId };
if (this.sprache !== null) {
params.sprache = this.sprache;
if (this.version !== null) {
params.version = this.version;
params.tab = this.tab;
}
}
const target = { name: 'content', params };
if (replace) {
this.$router.replace(target);
} else {
this.$router.push(target);
}
}
},
template: `
<div class="cms-admin h-100">
<horizontal-split :default-ratio="[25, 75]">
<template #left>
<cms-tree
ref="tree"
:active-content-id="contentId"
@select-content="selectContent"
></cms-tree>
</template>
<template #right>
<div class="cms-admin-main h-100 d-flex flex-column" v-if="contentId !== null">
<cms-content-header
:content-id="contentId"
:sprache="sprache"
:version="version"
:contentInfo="contentInfo"
@select-language-version="selectLanguageVersion"
@reload-content-info="loadContentInfo"
></cms-content-header>
<ul class="nav nav-tabs px-3">
<li class="nav-item" v-for="t in tabs" :key="t.key">
<a class="nav-link"
:class="{ active: tab === t.key }"
href="#"
@click.prevent="selectTab(t.key)"
>{{ $p.t(t.phrase) }}</a>
</li>
</ul>
<div class="cms-admin-tab-content flex-grow-1 overflow-auto">
<component
:is="activeTabComponent"
:content-id="contentId"
:sprache="sprache"
:version="version"
:contentInfo="contentInfo"
@reload-content-info="loadContentInfo"
@reload-tree="reloadTree"
@refresh-tree-node="refreshTreeNode"
@patch-tree-node="patchTreeNode"
@select-content="selectContent"
></component>
</div>
</div>
<div class="cms-admin-main h-100 d-flex align-items-center justify-content-center text-muted" v-else>
{{ $p.t('cms/keinEintragGewaehlt') }}
</div>
</template>
</horizontal-split>
<div v-if="busy" class="cms-admin-overlay">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{ $p.t('ui/loading') }}</span>
</div>
</div>
</div>
`
};
@@ -1,381 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
import {
catalog,
getDescriptor,
CONTENTCOMPONENT_CLASS,
CONTENTCOMPONENT_ATTR
} from '../../Contentcomponents/catalog.js';
/**
* The insert and edit dialogs for a contentcomponent marker.
*
* Lives apart from FieldWysiwyg so that file keeps to the editor itself. Everything that
* knows about the catalog, the marker markup and the dialogs is here.
*/
// Helper fields a picker adds to a dialog. They must not collide with a prop name, and
// markerHtml ignores them because it walks the catalog, not the dialog data.
const FELD_KATEGORIE = '__kategorie';
const FELD_DOKUMENT = '__dokument';
// Filled in createContentcomponentDialog. The factory is no Vue component, so the
// phrases plugin is handed in instead of read from an instance.
let t = key => key;
// A function, not a constant: the text is translated when the dialog is built.
const leereAuswahl = () => ({ value: '', text: t('ccAuswaehlen') });
// A prop source fills a dialog field with a list from the server. The key is the
// "source" value in the catalog. A prop without a known source gets a plain text field.
const SOURCES = {
oe: {
request: () => ApiCmsAdmin.getOrganisationseinheiten(),
item: oe => ({
value: oe.oe_kurzbz,
text: (oe.bezeichnung || oe.oe_kurzbz)
+ (oe.aktiv ? '' : ' (' + t('inaktiv') + ')')
})
},
dmskategorie: {
request: () => ApiCmsAdmin.getDmsKategorien(),
item: kat => ({
value: kat.kategorie_kurzbz,
text: (kat.bezeichnung || kat.kategorie_kurzbz) + ' (' + kat.anzahl + ')'
})
},
// Surname first
person: {
request: () => ApiCmsAdmin.getMitarbeiter(),
item: person => ({
value: person.uid,
text: person.nachname + ' ' + person.vorname + ' (' + person.uid + ')'
})
}
};
function escapeAttr(value)
{
return String(value)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
// Builds the marker. It stays empty: CIS4 renders the component into it, and every other
// renderer shows nothing. NOTE: if a marker disappears on save, check the TinyMCE
// serializer first. noneditable keeps the node, but an empty block is the exposed part.
function markerHtml(name, descriptor, data)
{
let attrs = '';
for (const propName of Object.keys(descriptor.props || {}))
{
const value = data[propName];
// false is an unchecked box. The absent attribute already means false.
if (value === '' || value === false || value === undefined || value === null)
continue;
attrs += ' data-' + propName + '="' + escapeAttr(value) + '"';
}
return '<div class="' + CONTENTCOMPONENT_CLASS + '" '
+ CONTENTCOMPONENT_ATTR + '="' + name + '"' + attrs + '></div>';
}
// Reads an existing marker back into dialog values.
function readMarker(node)
{
const name = node.getAttribute(CONTENTCOMPONENT_ATTR);
const descriptor = getDescriptor(name);
if (!descriptor)
return null;
const values = {};
for (const propName of Object.keys(descriptor.props || {}))
values[propName] = node.getAttribute('data-' + propName) || '';
return { name: name, values: values };
}
/**
* @param {object} options { api: the $api plugin, onChanged: called after a write }
*/
export function createContentcomponentDialog(options)
{
const api = options.api;
const onChanged = options.onChanged || function () {};
t = (key, args) => options.p.t('cms/' + key, args);
const sourceItems = {};
const sourceRequests = {};
// These lists are large and rarely needed. Load each one once, on demand.
function loadSource(key)
{
if (sourceRequests[key])
return sourceRequests[key];
const source = SOURCES[key];
sourceRequests[key] = api
.call(source.request())
.then(res => { sourceItems[key] = (res.data || []).map(source.item); })
.catch(() => { sourceItems[key] = []; });
return sourceRequests[key];
}
// Step one: pick the component. A second dialog then asks for its properties.
// Two dialogs avoid the redial logic a single changing dialog would need.
function openPicker(editor)
{
if (!catalog.length)
return;
editor.windowManager.open({
title: t('ccDialogTitel'),
initialData: { component: catalog[0].name },
body: {
type: 'panel',
items: [{
type: 'selectbox',
name: 'component',
label: t('ccContentcomponent'),
items: catalog.map(entry => ({
value: entry.name,
text: t(entry.labelPhrase)
}))
}]
},
buttons: [
{ type: 'cancel', text: t('abbrechen') },
{ type: 'submit', text: t('weiter'), primary: true }
],
onSubmit(dialog) {
const name = dialog.getData().component;
dialog.close();
openProperties(editor, name, null, null);
}
});
}
// Step two. node is null for an insert and the marker element for an edit.
function openProperties(editor, name, values, node)
{
const descriptor = getDescriptor(name);
if (!descriptor)
return;
const specs = Object.entries(descriptor.props || {});
const sources = specs
.map(([, spec]) => spec.source)
.filter((key, i, all) => SOURCES[key] && all.indexOf(key) === i);
// A document picker needs the category list even though no prop declares it.
if (specs.some(([, spec]) => spec.picker === 'dms') && sources.indexOf('dmskategorie') === -1)
sources.push('dmskategorie');
Promise.all(sources.map(loadSource)).then(() => {
// Documents of the category currently chosen in the dialog. A category change
// refills this and redials, because the items of a selectbox are fixed once
// the dialog is built.
let dokumente = [];
// A stored value that is no longer in the list, for example a person who left.
// Without this extra option the select would fall back to its first entry and
// silently replace the value on save. slice() keeps the cache clean.
function listeFuer(propName, spec)
{
const liste = (sourceItems[spec.source] || []).slice();
const wert = values && values[propName];
if (wert && !liste.some(eintrag => eintrag.value === wert))
liste.unshift({ value: wert, text: t('ccNichtInDerAuswahl', [wert]) });
return liste;
}
function itemsBauen()
{
return specs.reduce((alle, [propName, spec]) => {
if (SOURCES[spec.source])
{
alle.push({
type: 'selectbox',
name: propName,
label: spec.labelPhrase ? t(spec.labelPhrase) : propName,
items: listeFuer(propName, spec)
});
return alle;
}
if (spec.type === 'boolean')
{
alle.push({
type: 'checkbox',
name: propName,
label: spec.labelPhrase ? t(spec.labelPhrase) : propName
});
return alle;
}
alle.push({
type: 'input',
name: propName,
label: spec.labelPhrase ? t(spec.labelPhrase) : propName
});
if (spec.picker === 'dms')
{
alle.push({
type: 'selectbox',
name: propName + FELD_KATEGORIE,
label: t('ccKategorie'),
items: [leereAuswahl()].concat(sourceItems.dmskategorie || [])
});
alle.push({
type: 'selectbox',
name: propName + FELD_DOKUMENT,
label: t('ccDokumentHinzufuegen'),
items: [leereAuswahl()].concat(dokumente)
});
}
return alle;
}, []);
}
function konfigBauen(daten)
{
return {
title: t(descriptor.labelPhrase),
initialData: daten,
body: { type: 'panel', items: itemsBauen() },
onChange(dialog, details) {
aufAenderung(dialog, details.name);
},
buttons: [
{ type: 'cancel', text: t('abbrechen') },
{ type: 'submit', text: t('uebernehmen'), primary: true }
],
onSubmit(dialog) {
const html = markerHtml(name, descriptor, dialog.getData());
dialog.close();
if (node)
editor.dom.setOuterHTML(node, html);
else
editor.insertContent(html);
onChanged();
}
};
}
function aufAenderung(dialog, feld)
{
// A new category means a new document list, and only redial can replace
// the options of a selectbox.
if (feld.endsWith(FELD_KATEGORIE))
{
const ziel = feld.slice(0, -FELD_KATEGORIE.length);
const daten = dialog.getData();
const kategorie = daten[feld];
daten[ziel + FELD_DOKUMENT] = '';
if (!kategorie)
{
dokumente = [];
dialog.redial(konfigBauen(daten));
return;
}
api
.call(ApiCmsAdmin.getDmsKategorieDokumente(kategorie))
.then(res => {
dokumente = (res.data || []).map(dokument => ({
value: String(dokument.dms_id),
// A document outside the CIS search still renders here, so
// the editor has to see which one that is.
text: dokument.cis_suche
? dokument.name
: t('ccNichtInCisSuche', [dokument.name])
}));
})
.catch(() => { dokumente = []; })
.then(() => { dialog.redial(konfigBauen(daten)); });
return;
}
// Choosing a document appends it and clears the select, so the next one can
// be picked straight away. The reset fires this handler again with an empty
// value, which the guard below drops.
if (feld.endsWith(FELD_DOKUMENT))
{
const ziel = feld.slice(0, -FELD_DOKUMENT.length);
const daten = dialog.getData();
const wert = daten[feld];
if (!wert)
return;
const ids = String(daten[ziel] || '')
.split(',')
.map(teil => teil.trim())
.filter(teil => teil);
if (ids.indexOf(wert) === -1)
ids.push(wert);
const patch = {};
patch[ziel] = ids.join(', ');
patch[feld] = '';
dialog.setData(patch);
}
}
// Every field needs a start value, otherwise the dialog reports it as unknown.
const startdaten = {};
for (const [propName, spec] of specs)
{
if (spec.type === 'boolean')
{
startdaten[propName] = !!values && values[propName] === 'true';
continue;
}
const liste = sourceItems[spec.source];
startdaten[propName] = (values && values[propName])
|| (liste && liste.length ? liste[0].value : '');
if (spec.picker === 'dms')
{
startdaten[propName + FELD_KATEGORIE] = '';
startdaten[propName + FELD_DOKUMENT] = '';
}
}
editor.windowManager.open(konfigBauen(startdaten));
});
}
// Opens the property dialog of an existing marker.
function openMarker(editor, node)
{
const marker = readMarker(node);
if (marker)
openProperties(editor, marker.name, marker.values, node);
}
return {
openPicker: openPicker,
openMarker: openMarker
};
}
@@ -1,25 +0,0 @@
export default {
name: 'FieldString',
props: {
modelValue: { type: String, default: '' },
label: String,
required: Boolean,
disabled: Boolean,
type: { type: String, default: 'text' }
},
emits: ['update:modelValue'],
template: `
<div class="mb-3">
<label class="form-label">
{{ label }}<span v-if="required" class="text-danger"> *</span>
</label>
<input
class="form-control"
:type="type"
:value="modelValue"
:disabled="disabled"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
`
};
@@ -1,162 +0,0 @@
import { CONTENTCOMPONENT_CLASS, CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.js';
import { createContentcomponentDialog } from './ContentcomponentDialog.js';
export default {
name: 'FieldWysiwyg',
props: {
modelValue: { type: String, default: '' },
label: String,
required: Boolean,
disabled: Boolean
},
emits: ['update:modelValue'],
data() {
return {
editor: null,
editorId: 'cms-wysiwyg-' + Math.random().toString(36).substring(2, 11)
};
},
methods: {
getContent() {
return this.editor ? this.editor.getContent() : this.modelValue;
},
// Opens the legacy DMS browser and resolves with the relative URL it posts back.
// Used for images and links, where the editor needs the file preview of that
// browser. The contentcomponent dialog picks documents from a list instead, without
// leaving the dialog. The bridge in cms/tinymce_dms.php sends { typ, url } to
// window.opener and closes itself.
openDmsBrowser() {
const vm = this;
return new Promise(resolve => {
if (vm._dmsListener)
window.removeEventListener('message', vm._dmsListener);
const url = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/tinymce_dms.php';
// The window name selects the bridge path in cms/tinymce_dms.php and survives
// its internal navigation. The legacy admin opens the same page as 'DMS'.
window.open(url, 'DMS_CIS4', 'width=1400,height=850,scrollbars=1,resizable=1');
const onMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (!event.data || event.data.typ !== 'fhc-dms-selection') return;
window.removeEventListener('message', onMessage);
vm._dmsListener = null;
resolve(event.data.url);
};
vm._dmsListener = onMessage;
window.addEventListener('message', onMessage);
});
}
},
watch: {
modelValue(newVal) {
if (this.editor && this.editor.getContent() !== newVal) {
this.editor.setContent(newVal || '');
}
}
},
mounted() {
const vm = this;
// The dialogs live in their own module. This file stays with the editor itself.
this.contentcomponentDialog = createContentcomponentDialog({
api: this.$api,
// The dialog module is no component, so it cannot reach the phrases itself.
p: this.$p,
onChanged: () => vm.$emit('update:modelValue', vm.editor.getContent())
});
tinymce.init({
target: this.$refs.editorArea,
height: 500,
language: 'de',
// valid_elements: '*[*]' permits every tag and attribute. Existing content
// contains audio, video and source tags. A stricter setting deletes them silently.
valid_elements: '*[*]',
// A contentcomponent marker is one atomic block. The editor must not type into it.
noneditable_noneditable_class: CONTENTCOMPONENT_CLASS,
// The marker renders nothing here, so the editor has to show that it is there.
content_style: '.' + CONTENTCOMPONENT_CLASS + '{'
+ 'display:block;margin:.5rem 0;padding:.5rem;min-height:2.5rem;'
+ 'border:1px dashed #7a7a7a;background:#f2f2f2;cursor:pointer;}'
+ '.' + CONTENTCOMPONENT_CLASS + '::before{'
+ 'content:"Contentcomponent: " attr(' + CONTENTCOMPONENT_ATTR + ');'
+ 'font:12px monospace;color:#555;}',
forced_root_block: '',
// No style_formats, and no styleselect in the toolbar below. The six formats
// of the legacy admin are gone, and none of them earned a port.
//
// Three wrote a div, while every stylesheet targets a td: skin/styles/tw.css
// declares td.cmscontent, td.menubox and td.teambox. The live content agrees,
// it carries those classes on a td about 1400 times and on a div twice.
// Tablesorter wrote tablesorter_col_0, which no file in this repository
// reads, and skin/style.css.php never serves skin/tablesort.css.
//
// cmstable and screenshot_boxshadow did work, but only in the legacy
// renderer. public/css/Cis4/Cms.css binds its copies to #cms, and CIS4
// renders into #fhc-cms-content. CIS4 is the target, so they went too.
//
// A styling option for editors is a feature of its own. See
// docs/cms-darstellung-entwurf.md.
plugins: 'code lists link image table fullscreen paste searchreplace charmap directionality visualblocks anchor hr noneditable',
toolbar: [
'code | contentcomponent | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | fontsizeselect',
'pastetext | searchreplace | bullist numlist | outdent indent blockquote | undo redo | link unlink anchor image | forecolor backcolor',
'table | hr removeformat visualblocks | subscript superscript | charmap | ltr rtl | fullscreen'
],
// The relative form dms.php?id=N is mandatory. The legacy view runs under /cms/
// and resolves it directly. The CIS4 view replaces it in CmsLib::getContent
// with APP_ROOT . 'cms/dms.php'. An absolute URL breaks both paths.
file_picker_callback: (callback, value, meta) => {
vm.openDmsBrowser().then(callback);
},
setup: (editor) => {
vm.editor = editor;
editor.ui.registry.addButton('contentcomponent', {
text: vm.$p.t('cms/ccContentcomponent'),
tooltip: vm.$p.t('cms/ccDialogTitel'),
onAction: () => vm.contentcomponentDialog.openPicker(editor)
});
// A double click on a marker reopens its property dialog.
editor.on('dblclick', (event) => {
const node = editor.dom.getParent(event.target, '.' + CONTENTCOMPONENT_CLASS);
if (node)
vm.contentcomponentDialog.openMarker(editor, node);
});
editor.on('init', () => {
editor.setContent(vm.modelValue || '');
if (vm.disabled) editor.mode.set('readonly');
});
editor.on('change input', () => {
vm.$emit('update:modelValue', editor.getContent());
});
}
});
},
beforeUnmount() {
if (this._dmsListener) {
window.removeEventListener('message', this._dmsListener);
this._dmsListener = null;
}
if (this.editor && tinymce.get(this.editor.id)) {
tinymce.get(this.editor.id).remove();
this.editor = null;
}
},
template: `
<div class="mb-3">
<label class="form-label">
{{ label }}<span v-if="required" class="text-danger"> *</span>
</label>
<textarea ref="editorArea" :id="editorId"></textarea>
</div>
`
};
-72
View File
@@ -1,72 +0,0 @@
import FieldString from './FieldString.js';
import FieldWysiwyg from './FieldWysiwyg.js';
export default {
name: 'XsdForm',
components: {
'field-string': FieldString,
'field-wysiwyg': FieldWysiwyg
},
props: {
schema: Object,
modelValue: Object,
disabled: Boolean
},
emits: ['update:modelValue'],
methods: {
updateField(name, value) {
this.$emit('update:modelValue', { ...this.modelValue, [name]: value });
},
collectValues() {
const vals = { ...this.modelValue };
if (!this.schema?.fields) return vals;
for (const field of this.schema.fields) {
if (field.type === 'wysiwyg') {
const ref = this.$refs['wysiwyg-' + field.name];
const comp = Array.isArray(ref) ? ref[0] : ref;
if (comp?.getContent) vals[field.name] = comp.getContent();
}
}
return vals;
}
},
template: `
<div v-if="schema && schema.fields">
<template v-for="field in schema.fields" :key="field.name">
<field-wysiwyg
v-if="field.type === 'wysiwyg'"
:ref="'wysiwyg-' + field.name"
:modelValue="modelValue[field.name] || ''"
:label="field.name"
:required="field.required"
:disabled="disabled"
@update:modelValue="updateField(field.name, $event)"
></field-wysiwyg>
<div v-else-if="field.type === 'boolean'" class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
:checked="!!modelValue[field.name]"
:disabled="disabled"
@change="updateField(field.name, $event.target.checked)"
:id="'field-' + field.name"
>
<label class="form-check-label" :for="'field-' + field.name">
{{ field.name }}<span v-if="field.required" class="text-danger"> *</span>
</label>
</div>
</div>
<field-string
v-else
:modelValue="modelValue[field.name] || ''"
:label="field.name"
:required="field.required"
:disabled="disabled"
:type="field.type === 'date' ? 'date' : 'text'"
@update:modelValue="updateField(field.name, $event)"
></field-string>
</template>
</div>
`
};
@@ -1,180 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
import VersionSelect from './VersionSelect.js';
import LanguageSelect from './LanguageSelect.js';
export default {
name: 'CmsContentHeader',
components: {
'version-select': VersionSelect,
'sprache-select': LanguageSelect
},
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['select-language-version', 'reload-content-info'],
data() {
return {
titel: '',
allSprachen: [],
versionDetails: [],
// Target of the translation dropdown, used once the list grows past a pair.
neueSprache: ''
};
},
computed: {
languages() {
return this.contentInfo?.languages || [];
},
versions() {
if (!this.contentInfo?.versions || !this.sprache) return [];
return this.contentInfo.versions[this.sprache] || [];
},
availableSprachen() {
return this.allSprachen.filter(
s => !this.languages.includes(s.sprache)
);
}
},
watch: {
contentId() {
this.loadTitel();
this.loadVersionDetails();
},
sprache() {
this.loadTitel();
this.loadVersionDetails();
},
version() {
this.loadTitel();
}
},
methods: {
loadTitel() {
if (this.contentId == null || !this.sprache || !this.version) return;
this.$api
.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
.then(result => {
this.titel = result.data?.titel || '';
});
},
loadVersionDetails() {
if (this.contentId == null || !this.sprache) {
this.versionDetails = [];
return;
}
this.$api
.call(ApiCmsAdmin.getVersions(this.contentId, this.sprache))
.then(result => {
this.versionDetails = result.data || [];
});
},
loadSprachen() {
this.$api
.call(ApiCmsAdmin.getSprachen())
.then(result => {
this.allSprachen = result.data || [];
});
},
selectVersion(ver) {
this.$emit('select-language-version', this.sprache, ver);
},
selectLanguage(lang) {
const versions = this.contentInfo?.versions?.[lang] || [];
const maxVersion = versions.length ? Math.max(...versions) : 1;
this.$emit('select-language-version', lang, maxVersion);
},
createVersion() {
this.$api
.call(ApiCmsAdmin.postVersion(this.contentId, this.sprache))
.then(result => {
const newVersion = result.data.version;
this.$fhcAlert.alertSuccess(this.$p.t('cms/neueVersionAngelegt'));
this.$fhcAlert.alertInfo(this.$p.t('cms/neueVersionIstUnsichtbar'));
this.$emit('reload-content-info');
this.$emit('select-language-version', this.sprache, newVersion);
})
.catch(this.$fhcAlert.handleSystemError);
},
createTranslation(newSprache) {
if (!newSprache) return;
this.neueSprache = '';
this.$api
.call(ApiCmsAdmin.postTranslation(
this.contentId, this.sprache, this.version, newSprache
))
.then(() => {
this.$fhcAlert.alertSuccess(this.$p.t('cms/uebersetzungAngelegt'));
this.$emit('reload-content-info');
this.$emit('select-language-version', newSprache, this.version);
})
.catch(this.$fhcAlert.handleSystemError);
}
},
mounted() {
this.loadSprachen();
this.loadTitel();
this.loadVersionDetails();
},
template: `
<div class="px-3 pt-2 pb-2 border-bottom" v-if="contentInfo">
<div class="mb-2 fw-bold">
{{ $p.t('cms/contentId') }}: {{ contentId }}
| {{ $p.t('cms/version') }}: {{ version }}
| {{ $p.t('cms/sprache') }}: {{ sprache }}
| {{ $p.t('cms/titel') }}: {{ titel }}
</div>
<div class="d-flex align-items-center flex-wrap gap-2">
<div class="d-flex align-items-center gap-2">
<version-select
:versions="versions"
:version-details="versionDetails"
:version="version"
@select-version="selectVersion"
></version-select>
<sprache-select
:languages="languages"
:sprache="sprache"
@select-language="selectLanguage"
></sprache-select>
</div>
<div>
<button class="btn btn-sm btn-outline-primary"
@click="createVersion"
>{{ $p.t('cms/neueVersionAnlegen') }}</button>
</div>
<div class="d-flex align-items-center gap-1">
<!-- One button per missing language is fine for a pair. An institution
with more content languages would get a wall of them, each
carrying the full label. -->
<template v-if="availableSprachen.length > 2">
<select class="form-select form-select-sm w-auto"
v-model="neueSprache">
<option value="">{{ $p.t('cms/spracheWaehlen') }}</option>
<option v-for="s in availableSprachen" :key="s.sprache"
:value="s.sprache">{{ s.sprache }}</option>
</select>
<button class="btn btn-sm btn-outline-primary"
:disabled="!neueSprache"
@click="createTranslation(neueSprache)"
>{{ $p.t('cms/uebersetzungAnlegen') }}</button>
</template>
<template v-else>
<button v-for="s in availableSprachen" :key="s.sprache"
class="btn btn-sm btn-outline-primary"
@click="createTranslation(s.sprache)"
>{{ $p.t('cms/uebersetzungAnlegenIn', [s.sprache]) }}</button>
</template>
</div>
</div>
</div>
`
};
@@ -1,36 +0,0 @@
export default {
name: 'SpracheSelect',
props: {
languages: { type: Array, default: () => [] },
sprache: String
},
emits: ['select-language'],
computed: {
// Two languages read faster as a pair of buttons, and that is the case here. Other
// institutions run this software with more content languages, and there the row of
// buttons pushes the rest of the header off the line.
alsAuswahl() {
return this.languages.length > 2;
}
},
template: `
<select v-if="alsAuswahl"
class="form-select form-select-sm w-auto cms-sprache-select"
:title="$p.t('cms/sprache')"
:value="sprache"
@change="$emit('select-language', $event.target.value)"
>
<option v-for="lang in languages" :key="lang" :value="lang">{{ lang }}</option>
</select>
<div v-else class="btn-group btn-group-sm cms-sprache-select" role="group">
<button
v-for="lang in languages"
:key="lang"
type="button"
class="btn"
:class="lang === sprache ? 'btn-primary' : 'btn-outline-secondary'"
@click="$emit('select-language', lang)"
>{{ lang }}</button>
</div>
`
};
@@ -1,81 +0,0 @@
// Versions shown on each side of the open one.
const WINDOW = 3;
export default {
name: 'VersionSelect',
props: {
versions: { type: Array, default: () => [] },
versionDetails: { type: Array, default: () => [] },
version: Number
},
emits: ['select-version'],
computed: {
// A content can carry dozens of versions, and one button each breaks the header.
// Show the open version with three neighbours on each side. The first and the last
// stay reachable in one click, and a gap marks what the window leaves out.
// Below the threshold the window would hide nothing, so show every version.
items() {
const all = this.versions;
const active = all.indexOf(this.version);
if (active === -1 || all.length <= WINDOW * 2 + 3)
return all.map(value => ({ type: 'version', value }));
const from = Math.max(0, active - WINDOW);
const to = Math.min(all.length - 1, active + WINDOW);
const items = [];
if (from > 0) {
items.push({ type: 'version', value: all[0] });
// One version between the first and the window needs no gap marker.
if (from > 1) items.push({ type: 'gap', count: from - 1 });
}
for (let i = from; i <= to; i++)
items.push({ type: 'version', value: all[i] });
if (to < all.length - 1) {
if (to < all.length - 2)
items.push({ type: 'gap', count: all.length - 2 - to });
items.push({ type: 'version', value: all[all.length - 1] });
}
return items;
},
sichtbarMap() {
const map = {};
for (const v of this.versionDetails) {
map[v.version] = v.sichtbar;
}
return map;
}
},
methods: {
isSichtbar(ver) {
return this.sichtbarMap[ver] !== false;
}
},
template: `
<div class="btn-group btn-group-sm" role="group">
<template v-for="(item, idx) in items" :key="item.type + idx">
<button
v-if="item.type === 'version'"
type="button"
class="btn"
:class="[
item.value === version ? 'btn-primary' : 'btn-outline-secondary',
{ 'opacity-50': !isSichtbar(item.value) }
]"
@click="$emit('select-version', item.value)"
>{{ item.value }}</button>
<button
v-else
type="button"
class="btn btn-outline-secondary"
disabled
:title="$p.t('cms/weitereVersionen', [item.count])"
>&hellip;</button>
</template>
</div>
`
};
-308
View File
@@ -1,308 +0,0 @@
// Safety net for the spinner.
const CIS4_LOAD_TIMEOUT = 10000;
// Cis.js reads window.innerWidth and switches to its mobile layout below 767px. In a frame
// that is the width of the frame, so these widths drive the real layout and not a zoom.
// Its resize listener reacts on its own, which means no reload and no second long wait.
const CIS4_WIDTHS = {
mobile: 375,
desktop: null
};
export default {
name: 'CmsPreview',
props: {
contentId: Number,
sprache: String,
version: Number,
templateKurzbz: String,
// The legacy renderer runs no Vue, so it draws nothing for a contentcomponent
// marker. The CIS4 view mounts them, which is the whole point of switching.
hasContentcomponents: Boolean,
// False for a template whose xslt_xhtml_c4 is a copy of the legacy stylesheet.
// Such a template emits a whole HTML document, and the CIS4 renderer writes that
// into a div, so the layout is lost. Defaults true, so an older payload warns
// about nothing.
hasCis4Stylesheet: { type: Boolean, default: true },
sichtbar: Boolean
},
data() {
// The legacy renderer stays the default, so nothing changes until an editor asks
// for the other one. The choice survives a page switch, like the menu switch.
const stored = localStorage.getItem('cms/previewMode');
const mode = (stored === 'legacy' || stored === 'cis4') ? stored : 'legacy';
const width = localStorage.getItem('cms/previewWidth');
return {
mode: mode,
// True while the split handle is dragged. See onSplitterDown.
dragging: false,
cis4Width: CIS4_WIDTHS.hasOwnProperty(width) ? width : 'desktop',
// The CIS4 shell pulls its whole stack before it shows anything, which takes a
// while. Start covered when the frame is the one on screen.
cis4Loading: mode === 'cis4'
};
},
watch: {
mode(value) {
if (value === 'cis4') this.startCis4Load();
else this.stopCis4Poll();
},
// A new content, language or version reloads the frame.
cis4PreviewUrl() {
if (this.mode === 'cis4') this.startCis4Load();
}
},
computed: {
// The preview uses the legacy renderer cms/content.php. It reads xslt_xhtml, which
// holds a complete HTML document for all eleven templates. The CIS4 route reads
// xslt_xhtml_c4, where four templates hold a whole document instead of a fragment.
previewUrl() {
return FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cms/content.php?content_id=' + this.contentId
+ '&version=' + this.version
+ '&sprache=' + this.sprache
+ '&sichtbar=';
},
// The CIS4 route takes content_id only. Its Vue route is
// /CisVue/Cms/Content/:content_id, and extra segments hit the catch-all, which
// redirects to the dashboard. CIS4 picks the version itself and the language from
// the viewer profile.
cis4Url() {
return FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/CisVue/Cms/content/' + this.contentId;
},
// The second preview renders the content the way CIS4 does: the route loads the
// CIS4 shell, which brings its stylesheets, its Vue app and the contentcomponents.
// CISVUE-Header sets the body class in-frame by itself, and Cis.css hides the CIS4
// header for it, so the frame shows the content and no navigation.
// version travels as a route segment, because Cms/Content.js already declares that
// prop and the route passes props. sprache and preview travel as query parameters,
// because neither is a prop there.
cis4PreviewUrl() {
let url = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/CisVue/Cms/content/' + this.contentId;
if (this.version !== null)
url += '/' + this.version;
url += '?preview=1';
if (this.sprache)
url += '&sprache=' + encodeURIComponent(this.sprache);
return url;
},
// The overlay sits on the wrapper, so the wrapper carries the width. Otherwise the
// spinner would cover the whole pane while the frame is only 375px wide.
cis4WrapStyle() {
const px = CIS4_WIDTHS[this.cis4Width];
// content-box on purpose: the frame reads its own window.innerWidth, and with
// the usual border-box the border would eat two pixels. The preset is meant
// to be the width the CIS4 app sees, so it must not depend on the frame.
return px === null
? ''
: 'width: ' + px + 'px; box-sizing: content-box; max-width: 100%;'
+ ' margin: 0 auto;';
},
// The redirect template navigates away with JavaScript. In an iframe this can close
// the admin page. Therefore this case uses a link and not an iframe.
isRedirect() {
return this.templateKurzbz === 'redirect';
}
},
methods: {
selectMode(value) {
localStorage.setItem('cms/previewMode', value);
this.mode = value;
},
// The split handle sits right next to the frame. As soon as the pointer crosses
// into the frame, the frame document takes the mousemove and the mouseup, the
// listeners HorizontalSplit put on the parent window never fire, and the drag
// stops halfway. Turning pointer events off in the frame for the length of the
// drag lets those events through to the page underneath.
//
// Capture phase on purpose: HorizontalSplit.dragStart calls stopPropagation, so a
// listener that waits for the event to bubble back up never sees it at all.
onSplitterDown(event) {
const target = event.target;
if (target && target.closest && target.closest('.horizontalsplitter'))
this.dragging = true;
},
onSplitterUp() {
this.dragging = false;
},
selectWidth(value) {
localStorage.setItem('cms/previewWidth', value);
this.cis4Width = value;
// A frame resized by its parent gets no resize event of its own, so Cis.js
// would keep the window width it read at load and stay on that layout. Same
// origin, so tell the frame. That spares a reload of the whole CIS4 stack.
this.$nextTick(() => {
try {
const frame = this.$refs.cis4Frame;
const win = frame && frame.contentWindow;
if (win) win.dispatchEvent(new win.Event('resize'));
} catch (e) {
// Not reachable. The frame keeps the layout it loaded with.
}
});
},
startCis4Load() {
this.stopCis4Poll();
this.cis4Loading = true;
},
// The frame fires load once the CIS4 shell is there, but the shell then fetches the
// content over the API, so the load event alone clears the spinner too early and
// leaves the placeholder text on screen. The frame is same origin, so wait for the
// element the CIS4 renderer writes once it holds the content.
onCis4Load() {
this.stopCis4Poll();
const started = Date.now();
this.cis4Poll = setInterval(() => {
let done = false;
try {
const doc = this.$refs.cis4Frame && this.$refs.cis4Frame.contentDocument;
done = !!(doc && doc.getElementById('fhc-cms-content'));
} catch (e) {
// Not readable after all. Stop waiting rather than spin forever.
done = true;
}
if (done || Date.now() - started > CIS4_LOAD_TIMEOUT) {
this.stopCis4Poll();
this.cis4Loading = false;
}
}, 120);
},
stopCis4Poll() {
if (this.cis4Poll) {
clearInterval(this.cis4Poll);
this.cis4Poll = null;
}
},
neuLaden() {
if (this.$refs.previewFrame) {
this.$refs.previewFrame.src = this.previewUrl + '&t=' + Date.now();
}
if (this.$refs.cis4Frame) {
this.startCis4Load();
this.$refs.cis4Frame.src = this.cis4PreviewUrl + '&t=' + Date.now();
}
}
},
mounted() {
// On the window, not the document: HorizontalSplit ends the drag from a listener
// it puts on the window, and a mouseup aimed at the window never reaches a
// listener on the document. The window is the first node of the capture path, so
// it sees everything the document would and that case as well.
window.addEventListener('mousedown', this.onSplitterDown, true);
window.addEventListener('mouseup', this.onSplitterUp, true);
// Backstop. A missed mouseup would leave the frame unclickable.
window.addEventListener('blur', this.onSplitterUp);
},
beforeUnmount() {
window.removeEventListener('mousedown', this.onSplitterDown, true);
window.removeEventListener('mouseup', this.onSplitterUp, true);
window.removeEventListener('blur', this.onSplitterUp);
this.stopCis4Poll();
},
template: `
<div class="mt-3" :class="{ 'cms-preview-dragging': dragging }">
<div v-if="sichtbar === false" class="text-danger fw-bold mb-2">
{{ $p.t('cms/unsichtbarImLivesystem') }}
</div>
<div class="mb-2">
<a :href="cis4Url" target="_blank">{{ $p.t('cms/inCis4Ansehen') }}</a>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn"
:class="mode === 'legacy' ? 'btn-primary' : 'btn-outline-secondary'"
@click="selectMode('legacy')"
>{{ $p.t('cms/vorschauLegacy') }}</button>
<button type="button" class="btn"
:class="mode === 'cis4' ? 'btn-primary' : 'btn-outline-secondary'"
@click="selectMode('cis4')"
>{{ $p.t('cms/vorschauCis4') }}</button>
</div>
<div v-if="mode === 'cis4' && !isRedirect"
class="btn-group btn-group-sm" role="group">
<button type="button" class="btn"
:class="cis4Width === 'desktop' ? 'btn-primary' : 'btn-outline-secondary'"
:title="$p.t('cms/breiteDesktop')"
@click="selectWidth('desktop')"
><i class="fa-solid fa-display"></i></button>
<button type="button" class="btn"
:class="cis4Width === 'mobile' ? 'btn-primary' : 'btn-outline-secondary'"
:title="$p.t('cms/breiteMobil')"
@click="selectWidth('mobile')"
><i class="fa-solid fa-mobile-screen-button"></i></button>
</div>
</div>
<template v-if="mode === 'legacy'">
<div v-if="hasContentcomponents" class="alert alert-info py-2">
{{ $p.t('cms/contentcomponentsNurCis4') }}
</div>
<template v-if="isRedirect">
<a :href="previewUrl" target="_blank">
{{ $p.t('cms/vorschauInEigenemFenster') }}
</a>
</template>
<template v-else>
<iframe
ref="previewFrame"
:src="previewUrl"
style="width: 100%; min-height: 500px; border: 1px solid #ccc;"
></iframe>
</template>
</template>
<template v-else>
<div v-if="!hasCis4Stylesheet" class="alert alert-warning py-2">
{{ $p.t('cms/keinCis4Stylesheet') }}
</div>
<template v-if="isRedirect">
<a :href="cis4PreviewUrl" target="_blank">
{{ $p.t('cms/vorschauInEigenemFenster') }}
</a>
</template>
<template v-else>
<div class="position-relative border" :style="cis4WrapStyle">
<iframe
ref="cis4Frame"
:src="cis4PreviewUrl"
@load="onCis4Load"
style="width: 100%; min-height: 500px; border: 0;
display: block;"
></iframe>
<div v-if="cis4Loading"
class="position-absolute top-0 start-0 w-100 h-100 d-flex
flex-column align-items-center justify-content-center"
style="background: rgba(255, 255, 255, 0.85);">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{{ $p.t('ui/loading') }}</span>
</div>
<div class="mt-2 text-muted small">
{{ $p.t('cms/vorschauLaedt') }}
</div>
</div>
</div>
</template>
</template>
</div>
`
};
-153
View File
@@ -1,153 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsClickstats',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['select-content'],
data() {
return {
own: null,
ranked: [],
// The ranking stays absent until the editor asks for it, so an empty table and
// a table that was never fetched stay apart.
rankingLoaded: false,
since: null,
months: 12,
loadingOwn: false,
loadingRanking: false
};
},
watch: {
contentId() { this.loadOwn(); },
sprache() { this.loadOwn(); }
},
methods: {
// One counting query. Cheap enough to run whenever the tab or the period opens.
loadOwn() {
if (this.contentId == null) return;
this.loadingOwn = true;
this.$api
.call(ApiCmsAdmin.getClickCount(this.contentId, this.months))
.then(result => {
this.own = result.data.own;
this.since = result.data.since;
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.loadingOwn = false; });
},
// Groups the whole log, which takes seconds. Only on request.
loadRanking() {
this.loadingRanking = true;
this.$api
.call(ApiCmsAdmin.getClickRanking(this.months, this.sprache))
.then(result => {
this.ranked = result.data.ranked || [];
this.since = result.data.since;
this.rankingLoaded = true;
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.loadingRanking = false; });
},
// A new period always refreshes the cheap number. The ranking follows only once it
// is on screen, so changing the period never costs the long wait unasked.
onPeriodChange() {
this.loadOwn();
if (this.rankingLoaded) this.loadRanking();
},
// since arrives as an SQL timestamp. Format it the way the other tabs do.
formatDate(value) {
if (!value) return '';
const dt = luxon.DateTime.fromSQL(value);
return dt.isValid ? dt.toFormat('dd.MM.yyyy') : value;
},
isCurrent(row) {
return row.content_id === this.contentId;
},
onContentClick(content_id) {
this.$emit('select-content', content_id);
}
},
created() {
this.monthOptions = [3, 6, 12, 24, 0];
},
mounted() {
this.loadOwn();
},
template: `
<div class="p-3" v-if="contentId != null">
<div class="d-flex gap-2 align-items-center mb-3">
<label class="form-label mb-0">{{ $p.t('cms/zeitraum') }}</label>
<select class="form-select form-select-sm"
style="max-width: 220px"
v-model.number="months"
:disabled="loadingOwn || loadingRanking"
@change="onPeriodChange">
<option v-for="m in monthOptions" :key="m" :value="m">
{{ m ? $p.t('cms/letzteNMonate', [m]) : $p.t('cms/gesamterZeitraum') }}
</option>
</select>
<i v-if="loadingOwn" class="fa-solid fa-spinner fa-spin"></i>
</div>
<div class="form-text mb-3">
{{ since
? $p.t('cms/klicksSeit', [formatDate(since)])
: $p.t('cms/klicksGesamt') }}
</div>
<div class="mb-4">
<h5>{{ $p.t('cms/aufrufeDiesesEintrags') }}</h5>
<span class="fs-4">{{ own === null ? '-' : own }}</span>
</div>
<h5>{{ $p.t('cms/meistAufgerufeneEintraege') }}</h5>
<button v-if="!rankingLoaded"
class="btn btn-outline-primary"
:disabled="loadingRanking"
@click="loadRanking">
<i v-if="loadingRanking" class="fa-solid fa-spinner fa-spin me-1"></i>
{{ $p.t('cms/alleKlickstatistikenLaden') }}
</button>
<table v-else-if="ranked.length > 0" class="table table-sm table-striped">
<thead>
<tr>
<th>{{ $p.t('cms/rang') }}</th>
<th>ID</th>
<th>{{ $p.t('cms/titel') }}</th>
<th>{{ $p.t('cms/aufrufe') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in ranked" :key="row.content_id"
:class="isCurrent(row) ? 'table-primary' : ''">
<td>{{ idx + 1 }}</td>
<td>{{ row.content_id }}</td>
<td>
<a href="#" @click.prevent="onContentClick(row.content_id)">
{{ row.titel || $p.t('cms/ohneTitel') }}
</a>
</td>
<td>{{ row.hits }}</td>
</tr>
</tbody>
</table>
<div v-else class="text-muted">
{{ $p.t('cms/keineAufrufeImZeitraum') }}
</div>
</div>
`
};
-199
View File
@@ -1,199 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
import XsdForm from '../Form/XsdForm.js';
import CmsPreview from '../Preview.js';
import { CONTENTCOMPONENT_ATTR } from '../../Contentcomponents/catalog.js';
export default {
name: 'CmsContent',
components: {
'xsd-form': XsdForm,
'cms-preview': CmsPreview
},
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'refresh-tree-node', 'select-content'],
data() {
return {
schema: null,
values: {},
sperre: null,
sichtbar: true,
loading: false,
saving: false
};
},
computed: {
// The legacy renderer runs no Vue and draws nothing for a marker. The preview says
// so, but only while the legacy view is the one on screen.
hasContentcomponents() {
return Object.values(this.values || {}).some(
value => typeof value === 'string' && value.indexOf(CONTENTCOMPONENT_ATTR) !== -1
);
},
// The redirect template carries a URL, and its preview opens in a new window
// instead of an iframe. So without the lock nothing on this tab shows the values.
// Read only fields spare the editor a lock that blocks the content for 24 hours.
isRedirect() {
return this.contentInfo?.template_kurzbz === 'redirect';
},
lockState() {
if (!this.sperre || this.sperre.gesperrt_uid === null) return 'free';
if (this.sperre.own) return 'own';
if (this.sperre.expired) return 'expired';
return 'foreign';
},
lockInfo() {
if (!this.sperre || this.sperre.gesperrt_uid === null) return '';
const start = this.formatDateTime(this.sperre.start);
const ende = this.formatDateTime(this.sperre.expires);
if (this.sperre.own)
return this.$p.t('cms/eigeneSperre', [start, ende]);
const uid = this.sperre.gesperrt_uid;
if (this.sperre.expired)
return this.$p.t('cms/gesperrtVonSeitAbgelaufen', [uid, start, ende]);
return this.$p.t('cms/gesperrtVonSeit', [uid, start, ende]);
}
},
watch: {
contentId() { this.loadFormData(); },
sprache() { this.loadFormData(); },
version() { this.loadFormData(); }
},
methods: {
formatDateTime(dateStr) {
if (!dateStr) return this.$p.t('cms/unbekannt');
const dt = luxon.DateTime.fromSQL(dateStr);
return dt.isValid ? dt.toFormat('dd.MM.yyyy HH:mm') : dateStr;
},
loadFormData() {
if (this.contentId == null || !this.sprache || !this.version) return;
this.loading = true;
Promise.all([
this.$api.call(ApiCmsAdmin.getFormData(this.contentId, this.sprache, this.version)),
this.$api.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
])
.then(([formResult, spracheResult]) => {
const d = formResult.data;
this.schema = d.schema;
this.values = d.values || {};
this.sperre = d.sperre;
this.sichtbar = !!spracheResult.data.sichtbar;
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.loading = false; });
},
lock() {
this.$api
.call(ApiCmsAdmin.postLock(this.sperre.contentsprache_id))
.then(() => { this.loadFormData(); })
.catch(this.$fhcAlert.handleSystemError);
},
unlock() {
this.$api
.call(ApiCmsAdmin.deleteLock(this.sperre.contentsprache_id))
.then(() => { this.loadFormData(); })
.catch(this.$fhcAlert.handleSystemError);
},
forceUnlock() {
this.$api
.call(ApiCmsAdmin.deleteLockForced(this.sperre.contentsprache_id))
.then(() => { this.loadFormData(); })
.catch(this.$fhcAlert.handleSystemError);
},
save() {
const freshValues = this.$refs.xsdForm
? this.$refs.xsdForm.collectValues()
: this.values;
this.saving = true;
this.$api
.call(ApiCmsAdmin.putFormData(
this.contentId, this.sprache, this.version, freshValues
))
.then(() => {
this.$fhcAlert.alertSuccess(this.$p.t('cms/gespeichert'));
// The form holds the title, and the tree shows it.
this.$emit('refresh-tree-node', this.contentId);
if (this.$refs.preview) this.$refs.preview.neuLaden();
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.saving = false; });
}
},
mounted() {
this.loadFormData();
},
template: `
<div class="p-3" v-if="contentId != null">
<template v-if="!loading && sperre">
<div v-if="sperre.gesperrt_uid !== null"
class="alert"
:class="lockState === 'expired' ? 'alert-warning' : 'alert-info'">
{{ lockInfo }}
</div>
<template v-if="lockState === 'free'">
<button class="btn btn-primary mb-3" @click="lock">
{{ $p.t('cms/zurBearbeitungSperren') }}
</button>
</template>
<template v-else-if="lockState === 'own'">
<button class="btn btn-outline-secondary mb-3" @click="unlock">
{{ $p.t('cms/sperreFreigeben') }}
</button>
<xsd-form
ref="xsdForm"
:schema="schema"
v-model="values"
:disabled="false"
></xsd-form>
<button class="btn btn-primary mt-2 mb-3" @click="save" :disabled="saving">
{{ $p.t('cms/speichern') }}
</button>
</template>
<template v-else-if="lockState === 'expired'">
<button class="btn btn-warning mb-3" @click="lock">
{{ $p.t('cms/sperreUebernehmen') }}
</button>
</template>
<template v-else>
<button v-if="sperre.may_force" class="btn btn-danger mb-3" @click="forceUnlock">
{{ $p.t('cms/freigabeErzwingen') }}
</button>
</template>
<xsd-form
v-if="isRedirect && lockState !== 'own'"
:schema="schema"
:modelValue="values"
:disabled="true"
></xsd-form>
<cms-preview
ref="preview"
:content-id="contentId"
:sprache="sprache"
:version="version"
:template-kurzbz="contentInfo?.template_kurzbz"
:has-cis4-stylesheet="contentInfo?.has_cis4_stylesheet !== false"
:has-contentcomponents="hasContentcomponents"
:sichtbar="sichtbar"
></cms-preview>
</template>
</div>
`
};
-154
View File
@@ -1,154 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsDelete',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'reload-tree', 'refresh-tree-node', 'select-content'],
data() {
return {
usages: [],
loadingUsage: true
};
},
computed: {
hasRestrict() {
return this.usages.some(u => u.table !== 'campus.tbl_infoscreen_content');
},
// forward defaultLanguage prop to tabs. Using 'German' as fallback.
isLastVersion() {
if (!this.contentInfo?.versions || !this.sprache) return false;
const defaultLang = 'German';
const versions = this.contentInfo.versions[this.sprache];
return this.sprache === defaultLang && versions && versions.length === 1;
},
totalVersionCount() {
if (!this.contentInfo?.versions) return 0;
return Object.values(this.contentInfo.versions)
.reduce((sum, vers) => sum + vers.length, 0);
}
},
watch: {
contentId() { this.loadUsage(); }
},
methods: {
loadUsage() {
if (this.contentId == null) return;
this.loadingUsage = true;
this.$api
.call(ApiCmsAdmin.getUsage(this.contentId))
.then(result => {
this.usages = result.data || [];
})
.finally(() => { this.loadingUsage = false; });
},
deleteVersion() {
this.$fhcAlert.confirm({
message: this.$p.t('cms/versionLoeschenBestaetigen'),
acceptLabel: this.$p.t('ui/loeschen'),
acceptClass: 'p-button-danger'
}).then(confirmed => {
if (!confirmed) return;
return this.$api.call(
ApiCmsAdmin.deleteContentsprache(this.contentId, this.sprache, this.version)
);
}).then(result => {
if (!result) return;
this.$fhcAlert.alertSuccess(this.$p.t('cms/versionGeloescht'));
this.$emit('reload-content-info');
// The tree shows the title of the newest version, so the label can change.
this.$emit('refresh-tree-node', this.contentId);
}).catch(this.$fhcAlert.handleSystemError);
},
deleteContent() {
this.$fhcAlert.confirm({
message: this.$p.t('cms/contentLoeschenBestaetigen', [
this.contentInfo.languages.length,
this.totalVersionCount
]),
acceptLabel: this.$p.t('ui/loeschen'),
acceptClass: 'p-button-danger'
}).then(confirmed => {
if (!confirmed) return;
return this.$api.call(ApiCmsAdmin.deleteContent(this.contentId));
}).then(result => {
if (!result) return;
this.$fhcAlert.alertSuccess(this.$p.t('cms/contentGeloescht'));
// Children of the deleted entry can reappear under another parent, so the
// whole tree has to be rebuilt here.
this.$emit('reload-tree');
this.$nextTick(() => {
this.$emit('select-content', null);
});
}).catch(this.$fhcAlert.handleSystemError);
}
},
mounted() {
this.loadUsage();
},
// Five tables prevent the deletion with ON DELETE RESTRICT. tbl_infoscreen_content
// deletes its own rows with CASCADE. The legacy code shows neither case and hides the
// error. See Q5 in the contract.
template: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/dieseVersionLoeschen') }}</h5>
<div v-if="isLastVersion" class="alert alert-warning mb-3">
{{ $p.t('cms/letzteVersionNichtLoeschbar') }}
</div>
<div v-else class="mb-3">
<button class="btn btn-outline-danger" @click="deleteVersion">
{{ $p.t('cms/dieseVersionLoeschen') }}
</button>
</div>
<hr>
<h5>{{ $p.t('cms/ganzenContentLoeschen') }}</h5>
<div v-if="loadingUsage" class="mb-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
</div>
<template v-else>
<div v-if="usages.length === 0" class="alert alert-info mb-3">
{{ $p.t('cms/keineVerwendungGefunden') }}
</div>
<div v-else class="mb-3">
<div class="alert alert-warning">
{{ $p.t('cms/contentWirdVerwendetIn') }}
</div>
<ul class="list-group mb-3">
<li v-for="(u, i) in usages" :key="i"
class="list-group-item d-flex justify-content-between
align-items-center gap-3">
<span class="me-auto">
<span class="d-block">{{ u.label || '—' }}</span>
<!-- The value alone says nothing about where the entry
sits. The table is a database name and stays as it is. -->
<code class="small text-muted">{{ u.table }}</code>
</span>
<span v-if="u.table === 'campus.tbl_infoscreen_content'"
class="badge bg-danger text-nowrap">
{{ $p.t('cms/wirdMitgeloescht') }}
</span>
<span v-else class="badge bg-warning text-dark text-nowrap">
{{ $p.t('cms/verhindertLoeschen') }}
</span>
</li>
</ul>
</div>
</template>
<button class="btn btn-danger" @click="deleteContent"
:disabled="hasRestrict || loadingUsage">
{{ $p.t('cms/ganzenContentLoeschen') }}
</button>
</div>
`
};
-332
View File
@@ -1,332 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsHierarchy',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'reload-tree', 'refresh-tree-node', 'select-content'],
data() {
return {
parents: [],
childs: [],
possibleChilds: [],
selectedChild: '',
dragIndex: null,
orderBefore: null,
dropped: false,
savingOrder: false
};
},
computed: {
// tbl_contentchild has no unique index. The server permits a duplicate assignment.
// Therefore the select list hides the children that are already assigned.
availableChilds() {
const assigned = new Set(this.childs.map(c => c.child_content_id));
return this.possibleChilds.filter(c => !assigned.has(c.content_id));
}
},
watch: {
contentId() { this.loadAll(); },
sprache() { this.loadAll(); }
},
methods: {
loadAll() {
this.loadParents();
this.loadChilds();
this.loadPossibleChilds();
},
loadParents() {
if (this.contentId == null || !this.sprache) return;
this.$api
.call(ApiCmsAdmin.getParents(this.contentId, this.sprache))
.then(result => { this.parents = result.data || []; })
.catch(this.$fhcAlert.handleSystemError);
},
loadChilds() {
if (this.contentId == null || !this.sprache) return;
this.$api
.call(ApiCmsAdmin.getChilds(this.contentId, this.sprache))
.then(result => { this.childs = result.data || []; })
.catch(this.$fhcAlert.handleSystemError);
},
loadPossibleChilds() {
if (this.contentId == null || !this.sprache) return;
this.$api
.call(ApiCmsAdmin.getPossibleChilds(this.contentId, this.sprache))
.then(result => {
this.possibleChilds = result.data || [];
this.selectedChild = '';
})
.catch(this.$fhcAlert.handleSystemError);
},
childLabel(c) {
const titel = c.titel || this.$p.t('cms/ohneTitel');
return titel + ' (' + c.content_id + ')';
},
async addChild() {
if (!this.selectedChild) return;
try {
await this.$api.call(
ApiCmsAdmin.postChild(this.contentId, this.selectedChild)
);
this.loadAll();
// Only this branch changes, so refresh this branch.
this.$emit('refresh-tree-node', this.contentId);
this.$fhcAlert.alertSuccess(this.$p.t('cms/childHinzugefuegt'));
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
},
async removeChild(contentchild_id) {
const confirmed = await this.$fhcAlert.confirm({
message: this.$p.t('cms/zuordnungEntfernenBestaetigen')
});
if (!confirmed) return;
try {
await this.$api.call(ApiCmsAdmin.deleteChild(contentchild_id));
this.loadAll();
this.$emit('refresh-tree-node', this.contentId);
this.$fhcAlert.alertSuccess(this.$p.t('cms/childEntfernt'));
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
},
// Detaches this content from one parent. That is the same row as a child
// assignment, seen from the other end, so it uses the same endpoint.
async removeParent(contentchild_id) {
const confirmed = await this.$fhcAlert.confirm({
message: this.$p.t('cms/zuordnungEntfernenBestaetigen')
});
if (!confirmed) return;
try {
await this.$api.call(ApiCmsAdmin.deleteChild(contentchild_id));
this.loadParents();
// The content leaves one branch and may become a root, so rebuild the tree.
this.$emit('reload-tree');
this.$fhcAlert.alertSuccess(this.$p.t('cms/childEntfernt'));
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
},
async moveSort(contentchild_id, direction) {
try {
await this.$api.call(
ApiCmsAdmin.putChildSort(contentchild_id, direction)
);
this.loadChilds();
this.$emit('refresh-tree-node', this.contentId);
this.$fhcAlert.alertSuccess(this.$p.t('cms/sortierungGespeichert'));
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
},
// --- Drag and drop ---
// The dragged row follows the cursor: every row it enters swaps places with it.
// The drop then only has to write the order that already stands on screen.
onDragStart(index, event) {
this.dragIndex = index;
this.dropped = false;
this.orderBefore = this.childs.map(c => c.contentchild_id);
event.dataTransfer.effectAllowed = 'move';
// Firefox starts no drag without a payload.
event.dataTransfer.setData('text/plain', String(index));
},
onDragEnter(index) {
if (this.dragIndex === null || this.dragIndex === index) return;
const moved = this.childs.splice(this.dragIndex, 1)[0];
this.childs.splice(index, 0, moved);
this.dragIndex = index;
},
onDrop() {
this.dropped = true;
},
onDragEnd() {
if (this.dragIndex === null) return;
const before = this.orderBefore;
this.dragIndex = null;
this.orderBefore = null;
// Escape cancels the drag. Put the rows back.
if (!this.dropped) {
this.applyOrder(before);
return;
}
if (this.orderDiffers(before)) this.saveOrder(before);
},
applyOrder(contentchild_ids) {
if (!contentchild_ids) return;
const byId = new Map(this.childs.map(c => [c.contentchild_id, c]));
this.childs = contentchild_ids.map(id => byId.get(id)).filter(c => c);
},
orderDiffers(contentchild_ids) {
if (!contentchild_ids) return false;
return this.childs.some((c, i) => c.contentchild_id !== contentchild_ids[i]);
},
// One request for the whole order. A child moves over any distance, and the arrow
// endpoint swaps two neighbours, so it would need one request per position.
async saveOrder(before) {
this.savingOrder = true;
try {
await this.$api.call(ApiCmsAdmin.putChildOrder(
this.contentId,
this.childs.map(c => c.contentchild_id)
));
this.loadChilds();
this.$emit('refresh-tree-node', this.contentId);
this.$fhcAlert.alertSuccess(this.$p.t('cms/sortierungGespeichert'));
} catch (e) {
this.applyOrder(before);
this.$fhcAlert.handleSystemError(e);
} finally {
this.savingOrder = false;
}
},
onContentClick(content_id) {
this.$emit('select-content', content_id);
},
isFirst(index) {
return index === 0;
},
isLast(index) {
return index === this.childs.length - 1;
}
},
mounted() {
this.loadAll();
},
template: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/folgendeEintraegeSindUebergeordnet') }}</h5>
<table v-if="parents.length > 0" class="table table-sm table-striped">
<thead>
<tr>
<th>ID</th>
<th>{{ $p.t('cms/titel') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="parent in parents" :key="parent.contentchild_id">
<td>{{ parent.content_id }}</td>
<td>
<a href="#" @click.prevent="onContentClick(parent.content_id)">
{{ parent.titel || $p.t('cms/ohneTitel') }}
</a>
</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="removeParent(parent.contentchild_id)">
{{ $p.t('cms/entfernen') }}
</button>
</td>
</tr>
</tbody>
</table>
<div v-else class="text-muted">
{{ $p.t('cms/keineUebergeordnetenEintraege') }}
</div>
<h5 class="mt-4">{{ $p.t('cms/folgendeEintraegeSindUntergeordnet') }}</h5>
<template v-if="childs.length > 0">
<div class="form-text mb-1">
<i v-if="savingOrder" class="fa-solid fa-spinner fa-spin me-1"></i>
{{ $p.t('cms/ziehenZumSortieren') }}
</div>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>{{ $p.t('cms/sortierung') }}</th>
<th>ID</th>
<th>{{ $p.t('cms/titel') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(child, idx) in childs" :key="child.contentchild_id"
draggable="true"
:style="dragIndex === idx ? 'opacity:0.4' : ''"
@dragstart="onDragStart(idx, $event)"
@dragenter.prevent="onDragEnter(idx)"
@dragover.prevent
@drop.prevent="onDrop"
@dragend="onDragEnd">
<td class="text-nowrap">
<i class="fa-solid fa-grip-vertical text-muted me-1"
style="cursor:grab"
:title="$p.t('cms/ziehenZumSortieren')"></i>
{{ idx + 1 }}
<button class="btn btn-sm btn-link p-0 ms-1"
:disabled="isFirst(idx)"
@click="moveSort(child.contentchild_id, 'up')">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button class="btn btn-sm btn-link p-0 ms-1"
:disabled="isLast(idx)"
@click="moveSort(child.contentchild_id, 'down')">
<i class="fa-solid fa-arrow-down"></i>
</button>
</td>
<td>{{ child.child_content_id }}</td>
<td>
<a href="#" draggable="false"
@click.prevent="onContentClick(child.child_content_id)">
{{ child.titel || $p.t('cms/ohneTitel') }}
</a>
</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="removeChild(child.contentchild_id)">
{{ $p.t('cms/entfernen') }}
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div v-else class="text-muted">
{{ $p.t('cms/keineUntereintraege') }}
</div>
<div class="d-flex gap-2 align-items-end mt-3">
<select class="form-select" v-model="selectedChild" style="max-width: 500px;">
<option value="" disabled></option>
<option v-for="c in availableChilds" :key="c.content_id"
:value="c.content_id">
{{ childLabel(c) }}
</option>
</select>
<button class="btn btn-primary" @click="addChild" :disabled="!selectedChild">
{{ $p.t('cms/hinzufuegen') }}
</button>
</div>
</div>
`
};
-65
View File
@@ -1,65 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsHistory',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'reload-tree', 'select-content'],
data() {
return {
versions: []
};
},
watch: {
contentId() { this.loadVersions(); },
sprache() { this.loadVersions(); }
},
methods: {
loadVersions() {
if (this.contentId == null || !this.sprache) return;
this.$api
.call(ApiCmsAdmin.getVersions(this.contentId, this.sprache))
.then(result => {
this.versions = result.data || [];
})
.catch(this.$fhcAlert.handleSystemError);
},
formatDate(dateStr) {
if (!dateStr) return '';
try {
const dt = luxon.DateTime.fromSQL(dateStr);
return dt.isValid ? dt.toFormat('dd.MM.yyyy') : dateStr;
} catch (e) {
return dateStr;
}
}
},
mounted() {
this.loadVersions();
},
template: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/versionen') }}</h5>
<div v-for="v in versions" :key="v.version"
class="mb-3 p-2 border rounded"
:class="{ 'border-primary border-2': v.version === version }">
<div class="fw-bold">
{{ $p.t('cms/version') }} {{ v.version }}
<span v-if="!v.sichtbar" class="badge bg-secondary ms-2">
{{ $p.t('cms/unsichtbar') }}
</span>
</div>
<div>{{ $p.t('cms/erstelltAmVon', [formatDate(v.insertamum), v.insertvon]) }}</div>
<div v-if="v.updatevon">
{{ $p.t('cms/letzteAenderungVonAm', [formatDate(v.updateamum), v.updatevon]) }}
</div>
</div>
</div>
`
};
@@ -1,143 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsPermissions',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'patch-tree-node', 'select-content'],
data() {
return {
gruppen: [],
allGruppen: [],
selectedGruppe: '',
allGruppenLoaded: false
};
},
computed: {
availableGruppen() {
const assigned = new Set(this.gruppen.map(g => g.gruppe_kurzbz));
return this.allGruppen.filter(g => !assigned.has(g.gruppe_kurzbz));
}
},
watch: {
contentId() { this.loadGruppen(); }
},
methods: {
loadGruppen() {
if (this.contentId == null) return Promise.resolve();
return this.$api
.call(ApiCmsAdmin.getGruppen(this.contentId))
.then(result => {
this.gruppen = result.data || [];
this.selectedGruppe = '';
})
.catch(this.$fhcAlert.handleSystemError);
},
// A group only draws the lock icon. It never moves a node, so the tree needs no
// request here.
patchTree() {
this.$emit('patch-tree-node', {
content_id: this.contentId,
patch: { groups: this.gruppen.map(g => g.gruppe_kurzbz) }
});
},
loadAllGruppen() {
if (this.allGruppenLoaded) return;
this.$api
.call(ApiCmsAdmin.getAllGruppen())
.then(result => {
this.allGruppen = result.data || [];
this.allGruppenLoaded = true;
})
.catch(this.$fhcAlert.handleSystemError);
},
async addGruppe() {
if (!this.selectedGruppe) return;
try {
await this.$api.call(
ApiCmsAdmin.postGruppe(this.contentId, this.selectedGruppe)
);
await this.loadGruppen();
this.patchTree();
this.$fhcAlert.alertSuccess(this.$p.t('cms/gruppeHinzugefuegt'));
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
},
async removeGruppe(gruppe_kurzbz) {
if (this.gruppen.length <= 1) {
const confirmed = await this.$fhcAlert.confirm({
message: this.$p.t('cms/letzteGruppeEntfernenBestaetigen')
});
if (!confirmed) return;
}
try {
await this.$api.call(
ApiCmsAdmin.deleteGruppe(this.contentId, gruppe_kurzbz)
);
await this.loadGruppen();
this.patchTree();
this.$fhcAlert.alertSuccess(this.$p.t('cms/gruppeEntfernt'));
} catch (e) {
this.$fhcAlert.handleSystemError(e);
}
}
},
mounted() {
this.loadAllGruppen();
this.loadGruppen();
},
template: `
<div class="p-3" v-if="contentId != null">
<template v-if="gruppen.length > 0">
<h5>{{ $p.t('cms/folgendeGruppenDuerfenAnsehen') }}</h5>
<table class="table table-sm table-striped">
<thead>
<tr>
<th>{{ $p.t('cms/gruppe') }}</th>
<th>{{ $p.t('cms/bezeichnung') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="g in gruppen" :key="g.gruppe_kurzbz">
<td>{{ g.gruppe_kurzbz }}</td>
<td>{{ g.bezeichnung }}</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@click="removeGruppe(g.gruppe_kurzbz)">
{{ $p.t('cms/entfernen') }}
</button>
</td>
</tr>
</tbody>
</table>
</template>
<div v-else class="alert alert-warning">
{{ $p.t('cms/seiteDarfVonAllenAngezeigtWerden') }}
</div>
<div class="d-flex gap-2 align-items-end mt-3">
<select class="form-select" v-model="selectedGruppe" style="max-width: 400px;">
<option value="" disabled></option>
<option v-for="g in availableGruppen" :key="g.gruppe_kurzbz"
:value="g.gruppe_kurzbz">
{{ g.gruppe_kurzbz }} — {{ g.bezeichnung }}
</option>
</select>
<button class="btn btn-primary" @click="addGruppe" :disabled="!selectedGruppe">
{{ $p.t('cms/hinzufuegen') }}
</button>
</div>
</div>
`
};
-185
View File
@@ -1,185 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
export default {
name: 'CmsProperties',
props: {
contentId: Number,
sprache: String,
version: Number,
contentInfo: Object
},
emits: ['reload-content-info', 'refresh-tree-node', 'select-content'],
data() {
return {
titel: '',
sichtbar: false,
template_kurzbz: '',
oe_kurzbz: '',
aktiv: false,
menu_open: false,
beschreibung: '',
templates: [],
organisationseinheiten: [],
templateChanged: false,
saving: false
};
},
watch: {
contentId() { this.loadData(); },
sprache() { this.loadData(); },
version() { this.loadData(); },
contentInfo() { this.loadContentFields(); }
},
methods: {
loadData() {
this.templateChanged = false;
if (this.contentId == null || !this.sprache || !this.version) return;
this.$api
.call(ApiCmsAdmin.getContentsprache(this.contentId, this.sprache, this.version))
.then(result => {
const d = result.data;
this.titel = d.titel || '';
this.sichtbar = !!d.sichtbar;
});
this.loadContentFields();
},
loadContentFields() {
if (!this.contentInfo) return;
this.template_kurzbz = this.contentInfo.template_kurzbz || '';
this.oe_kurzbz = this.contentInfo.oe_kurzbz || '';
this.aktiv = !!this.contentInfo.aktiv;
this.menu_open = !!this.contentInfo.menu_open;
this.beschreibung = this.contentInfo.beschreibung || '';
},
loadLookups() {
this.$api
.call(ApiCmsAdmin.getTemplates())
.then(result => { this.templates = result.data || []; });
this.$api
.call(ApiCmsAdmin.getOrganisationseinheiten())
.then(result => { this.organisationseinheiten = result.data || []; });
},
onTemplateChange() {
this.templateChanged = true;
},
oeLabel(oe) {
let label = oe.organisationseinheittyp_kurzbz + ' ' + oe.bezeichnung;
if (!oe.aktiv) label += ' (' + this.$p.t('cms/inaktiv') + ')';
return label;
},
save() {
this.saving = true;
this.$api
.call(ApiCmsAdmin.putProperties(
this.contentId, this.sprache, this.version,
this.template_kurzbz, this.oe_kurzbz,
this.aktiv, this.menu_open, this.beschreibung,
this.titel, this.sichtbar
))
.then(() => {
this.$fhcAlert.alertSuccess(this.$p.t('cms/gespeichert'));
this.$emit('reload-content-info');
// Title, active flag and organisational unit all show in the tree.
this.$emit('refresh-tree-node', this.contentId);
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.saving = false; });
}
},
mounted() {
this.loadLookups();
this.loadData();
},
template: `
<div class="p-3" v-if="contentId != null">
<h5>{{ $p.t('cms/giltFuerAlleSprachen') }}</h5>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/vorlage') }}</label>
<div class="col-sm-9">
<select class="form-select" v-model="template_kurzbz"
@change="onTemplateChange">
<option v-for="t in templates" :key="t.template_kurzbz"
:value="t.template_kurzbz">{{ t.bezeichnung }}</option>
</select>
<div v-if="templateChanged" class="text-danger mt-1 fw-bold">
{{ $p.t('cms/vorlageWarnung') }}
</div>
</div>
</div>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/organisationseinheit') }}</label>
<div class="col-sm-9">
<select class="form-select" v-model="oe_kurzbz">
<option v-for="oe in organisationseinheiten" :key="oe.oe_kurzbz"
:value="oe.oe_kurzbz"
:class="{ 'text-muted': !oe.aktiv }">{{ oeLabel(oe) }}</option>
</select>
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="aktiv"
id="prop-aktiv">
<label class="form-check-label" for="prop-aktiv">
{{ $p.t('cms/aktiv') }}
</label>
</div>
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="menu_open"
id="prop-menu-open">
<label class="form-check-label" for="prop-menu-open">
{{ $p.t('cms/menuOpen') }}
</label>
</div>
</div>
</div>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/beschreibung') }}</label>
<div class="col-sm-9">
<textarea class="form-control" rows="3" v-model="beschreibung"></textarea>
</div>
</div>
<hr>
<h5>{{ $p.t('cms/giltFuerDieseVersion') }}</h5>
<div class="mb-3 row">
<label class="col-sm-3 col-form-label">{{ $p.t('cms/titel') }}</label>
<div class="col-sm-9">
<input type="text" class="form-control" v-model="titel" maxlength="256">
</div>
</div>
<div class="mb-3 row">
<div class="col-sm-9 offset-sm-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" v-model="sichtbar"
id="prop-sichtbar">
<label class="form-check-label" for="prop-sichtbar">
{{ $p.t('cms/sichtbar') }}
</label>
</div>
</div>
</div>
<hr>
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-primary" @click="save" :disabled="saving">
{{ $p.t('cms/speichern') }}
</button>
</div>
</div>
`
};
@@ -1,521 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
import TreeFilter, { emptyCriteria } from './TreeFilter.js';
import MenuSwitch from './MenuSwitch.js';
// The meta dates are SQL timestamps. Format them like the history tab does.
function formatDay(value) {
if (!value) return '';
const dt = luxon.DateTime.fromSQL(value);
return dt.isValid ? dt.toFormat('dd.MM.yyyy') : value;
}
// A day string of the meta value. The inputs deliver YYYY-MM-DD, so compare as text.
function inRange(value, from, to) {
if (!from && !to) return true;
if (!value) return false;
const day = value.substring(0, 10);
if (from && day < from) return false;
if (to && day > to) return false;
return true;
}
// The meta fields that hold a number. The rest hold an SQL timestamp.
const NUMERIC_FIELDS = ['childcount', 'contentlength'];
// Sort key of one node. A content without meta sorts last in a descending list.
function sortValue(meta, contentId, field) {
const numeric = NUMERIC_FIELDS.indexOf(field) !== -1;
const m = meta[contentId];
if (!m) return numeric ? -1 : '';
if (numeric) return m[field];
return m[field] || '';
}
const TreeNode = {
name: 'CmsTreeNode',
inject: ['treeSelect', 'treeToggle'],
props: {
node: Object,
activeContentId: Number,
expandedKeys: Object,
flat: Boolean,
meta: Object,
metric: String,
// Ancestors of this node, from the top down. Only the nodes of the flat result
// list get one; a child inside the subtree sits under its parent already.
ancestors: Array,
// A node that is context rather than a hit. It and its children draw back.
muted: Boolean
},
computed: {
// Shows the value the active filter or sort works on, so the order is readable.
badge() {
if (!this.metric || !this.meta) return null;
const m = this.meta[this.node.content_id];
if (!m) return null;
if (NUMERIC_FIELDS.indexOf(this.metric) !== -1) return String(m[this.metric]);
return formatDay(m[this.metric]) || null;
},
badgeEmpty() {
return NUMERIC_FIELDS.indexOf(this.metric) !== -1 && this.badge === '0';
},
hasChildren() {
return this.node.children && this.node.children.length > 0;
},
isExpanded() {
return !!this.expandedKeys[this.node.content_id];
},
isActive() {
return this.activeContentId != null
&& this.node.content_id === this.activeContentId;
},
// The hit keeps its place; the path above it steps in one level at a time.
indent() {
return this.ancestors ? this.ancestors.length : 0;
},
labelStyle() {
const s = {};
if (this.isActive) {
s.fontWeight = 'bold';
s.background = '#e8f0fe';
s.borderRadius = '3px';
s.padding = '0 4px';
}
if (!this.node.aktiv) {
s.opacity = '0.5';
}
return s;
}
},
template: `
<li class="py-1" :class="{ 'cms-tree-context': muted }">
<div v-for="(a, i) in ancestors" :key="a.content_id"
class="cms-tree-ancestor"
:style="{ marginLeft: (i * 1.1) + 'em' }">
<!-- treeSelect reads entitled and content_id, so an ancestor row is a node
as far as it is concerned. An ancestor of a unit the editor is not
entitled for stays plain text, the same as in the tree itself. -->
<a v-if="a.entitled" href="#"
@click.prevent="treeSelect(a)"
>{{ a.titel || a.content_id }} ({{ a.content_id }})</a>
<span v-else>{{ a.titel || a.content_id }} ({{ a.content_id }})</span>
</div>
<div :style="indent ? { marginLeft: (indent * 1.1) + 'em' } : null">
<span v-if="!flat && hasChildren"
style="cursor:pointer;user-select:none"
@click="treeToggle(node)">
<i class="fa-solid fa-fw"
:class="isExpanded ? 'fa-caret-down' : 'fa-caret-right'"></i>
</span>
<span v-else-if="!flat" style="display:inline-block;width:1.25em"></span>
<a v-if="node.entitled"
href="#"
class="text-decoration-none"
:style="labelStyle"
@click.prevent="treeSelect(node)">{{ node.titel }} ({{ node.content_id }})</a>
<span v-else :style="!node.aktiv ? 'opacity:0.5' : ''">{{ node.titel }} ({{ node.content_id }})</span>
<i v-if="node.groups && node.groups.length"
class="fa-solid fa-lock fa-sm ms-1 cms-tree-lock"
:title="node.groups.join(', ')"></i>
<span v-if="badge !== null"
class="badge ms-1"
:class="badgeEmpty ? 'bg-light text-muted' : 'bg-secondary'">{{ badge }}</span>
<ul v-if="!flat && isExpanded && hasChildren" class="list-unstyled ms-3">
<cms-tree-node
v-for="child in node.children"
:key="child.content_id"
:node="child"
:active-content-id="activeContentId"
:expanded-keys="expandedKeys"
:flat="flat"
:meta="meta"
:metric="metric"
:muted="muted || indent > 0"
></cms-tree-node>
</ul>
</div>
</li>
`
};
export default {
name: 'CmsTree',
components: {
'cms-tree-node': TreeNode,
'tree-filter': TreeFilter,
'menu-switch': MenuSwitch
},
props: {
activeContentId: Number
},
emits: ['select-content'],
data() {
const stored = localStorage.getItem('cms/menu');
return {
menu: (stored === 'content' || stored === 'news') ? stored : 'content',
filter: '',
nodes: [],
loading: false,
expandedKeys: {},
criteria: emptyCriteria(),
meta: null,
metaLoading: false,
// A flat list drops the context a generic title needs. The toggle puts the
// way down to each hit back, and the choice survives a page switch.
showHierarchy: localStorage.getItem('cms/treeHierarchie') === '1',
ancestors: {},
ancestorsLoading: false
};
},
computed: {
// The news menu is a flat list without the meta fields.
advanced() {
return this.menu === 'content';
},
// Every field but the template comes from the meta request.
needsMeta() {
const c = this.criteria;
return c.mine
|| !!c.updatedFrom || !!c.updatedTo
|| !!c.createdFrom || !!c.createdTo
|| c.minChildren !== '' || c.maxChildren !== ''
|| c.minLength !== '' || c.maxLength !== '' || c.emptyOnly
|| c.sort === 'updateamum' || c.sort === 'insertamum'
|| c.sort === 'childcount' || c.sort === 'contentlength';
},
narrowing() {
const c = this.criteria;
return !!c.template_kurzbz || c.mine
|| !!c.updatedFrom || !!c.updatedTo
|| !!c.createdFrom || !!c.createdTo
|| c.minChildren !== '' || c.maxChildren !== ''
|| c.minLength !== '' || c.maxLength !== '' || c.emptyOnly;
},
active() {
return this.advanced && (this.narrowing || this.criteria.sort !== '');
},
// A hierarchical tree already shows where a node sits. Only a flat list, from a
// search or from a filter, needs the path spelled out.
isFlat() {
return this.active || this.menu === 'news' || !!this.filter;
},
showPaths() {
return this.showHierarchy && this.isFlat;
},
// The value the badge shows. The filter drives it, the sort order falls back.
metric() {
const c = this.criteria;
if (!this.active) return null;
if (c.emptyOnly || c.minLength !== '' || c.maxLength !== '') return 'contentlength';
if (c.minChildren !== '' || c.maxChildren !== '') return 'childcount';
if (c.updatedFrom || c.updatedTo) return 'updateamum';
if (c.createdFrom || c.createdTo) return 'insertamum';
if (c.sort !== '' && c.sort !== 'titel') return c.sort;
return null;
},
// Flat on purpose: a match sits at any depth, so per-level sorting would hide it.
visibleNodes() {
if (!this.active) return this.nodes;
const flatList = [];
const walk = (nodes) => {
for (const node of nodes) {
flatList.push(node);
if (node.children && node.children.length) walk(node.children);
}
};
walk(this.nodes);
return this.sortNodes(flatList.filter(node => this.matches(node)));
}
},
provide() {
return {
treeSelect: (node) => { this.selectNode(node); },
treeToggle: (node) => { this.toggleNode(node); }
};
},
watch: {
menu() {
this.loadTree();
},
activeContentId(id) {
if (id != null) {
this.expandPathTo(id);
}
}
},
methods: {
// Keeps the expanded branches. A reload must not fold the tree back up.
loadTree() {
this.loading = true;
return this.$api
.call(ApiCmsAdmin.getTree(this.menu, this.filter))
.then(result => {
this.nodes = result.data;
if (this.activeContentId != null) {
this.expandPathTo(this.activeContentId);
}
})
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.loading = false; });
},
// --- Public: the parent calls these instead of rebuilding the component ---
reload() {
// A change moves a date or a child count. Drop the cached meta.
this.meta = null;
if (this.needsMeta) this.loadMeta();
return this.loadTree();
},
// Replaces one branch. Use it after a change to the children, the title, the
// active flag or the organisational unit of that content.
refreshNode(contentId) {
// The news menu is a flat list of its own. Only a full reload fits it.
if (this.menu === 'news' || contentId == null) return this.loadTree();
const slot = this.findSlot(this.nodes, contentId);
// Not in the tree, so the change may add a new root. Rebuild.
if (slot === null) return this.loadTree();
return this.$api
.call(ApiCmsAdmin.getSubtree(contentId))
.then(result => {
if (result.data) {
slot.list.splice(slot.index, 1, result.data);
} else {
slot.list.splice(slot.index, 1);
}
if (this.activeContentId != null) {
this.expandPathTo(this.activeContentId);
}
})
.catch(this.$fhcAlert.handleSystemError);
},
// Writes known fields straight into one node. Groups do not change the structure,
// so they need no request.
patchNode(contentId, patch) {
const slot = this.findSlot(this.nodes, contentId);
if (slot === null) return;
Object.assign(slot.node, patch);
},
findSlot(nodes, contentId) {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].content_id === contentId) {
return { list: nodes, index: i, node: nodes[i] };
}
if (nodes[i].children && nodes[i].children.length) {
const found = this.findSlot(nodes[i].children, contentId);
if (found !== null) return found;
}
}
return null;
},
expandPathTo(targetId) {
const search = (nodes) => {
for (const node of nodes) {
if (node.content_id === targetId) return true;
if (node.children && node.children.length) {
if (search(node.children)) {
this.expandedKeys[node.content_id] = true;
return true;
}
}
}
return false;
};
search(this.nodes);
},
toggleNode(node) {
if (this.expandedKeys[node.content_id]) {
delete this.expandedKeys[node.content_id];
} else {
this.expandedKeys[node.content_id] = true;
}
},
selectNode(node) {
if (node.entitled) {
this.$emit('select-content', node.content_id);
}
},
matches(node) {
const c = this.criteria;
if (c.template_kurzbz && node.template_kurzbz !== c.template_kurzbz) return false;
if (!this.needsMeta) return true;
// The meta arrives after the tree. Hide nothing until it is there.
if (this.meta === null) return true;
const m = this.meta[node.content_id];
if (!m) return false;
if (c.mine && !m.mine) return false;
if (!inRange(m.updateamum, c.updatedFrom, c.updatedTo)) return false;
if (!inRange(m.insertamum, c.createdFrom, c.createdTo)) return false;
if (c.minChildren !== '' && m.childcount < Number(c.minChildren)) return false;
if (c.maxChildren !== '' && m.childcount > Number(c.maxChildren)) return false;
if (c.emptyOnly && m.contentlength !== 0) return false;
if (c.minLength !== '' && m.contentlength < Number(c.minLength)) return false;
if (c.maxLength !== '' && m.contentlength > Number(c.maxLength)) return false;
return true;
},
// Descending for the numbers and the dates: the largest and the newest come first.
sortNodes(list) {
const sort = this.criteria.sort;
if (!sort) return list;
if (sort === 'titel') {
return list.slice().sort(
(a, b) => (a.titel || '').localeCompare(b.titel || '')
);
}
const meta = this.meta || {};
return list.slice().sort((a, b) => {
const va = sortValue(meta, a.content_id, sort);
const vb = sortValue(meta, b.content_id, sort);
if (va === vb) return 0;
return va < vb ? 1 : -1;
});
},
// One request for the whole tree. Cache it, the filter changes often.
loadMeta() {
if (this.meta !== null || this.metaLoading) return;
this.metaLoading = true;
this.$api
.call(ApiCmsAdmin.getTreeMeta())
.then(result => { this.meta = result.data || {}; })
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.metaLoading = false; });
},
toggleHierarchy() {
this.showHierarchy = !this.showHierarchy;
localStorage.setItem('cms/treeHierarchie', this.showHierarchy ? '1' : '0');
if (!this.showHierarchy) return;
// The point of the toggle is the context around a hit, and the subtree is half
// of it. A collapsed node would show the way down to the hit and nothing under
// it. Opening the hits leaves every caret free to collapse again.
for (const node of this.visibleNodes)
this.expandedKeys[node.content_id] = true;
this.loadAncestors();
},
// One request for the whole list. Asking per node would be a request per hit.
loadAncestors() {
if (!this.showPaths) return;
const ids = this.visibleNodes.map(node => node.content_id);
if (!ids.length) {
this.ancestors = {};
return;
}
this.ancestorsLoading = true;
this.$api
.call(ApiCmsAdmin.getAncestors(ids))
.then(result => { this.ancestors = result.data || {}; })
.catch(this.$fhcAlert.handleSystemError)
.finally(() => { this.ancestorsLoading = false; });
},
pathFor(contentId) {
return this.showPaths ? (this.ancestors[contentId] || []) : null;
},
onCriteria(criteria) {
this.criteria = criteria;
if (this.needsMeta) this.loadMeta();
this.loadAncestors();
},
onFilter(text) {
this.filter = text;
this.loadTree().then(() => { this.loadAncestors(); });
},
createEntry(parentId) {
this.$api
.call(ApiCmsAdmin.postContent(parentId))
.then(result => {
const newId = result.data.content_id;
this.$fhcAlert.alertSuccess(this.$p.t('cms/eintragAngelegt'));
// A new root is not under any branch, so only a reload shows it.
const refreshed = parentId == null
? this.loadTree()
: this.refreshNode(parentId);
// Select after the node exists, or expandPathTo finds nothing.
return refreshed.then(() => {
this.$emit('select-content', newId);
});
})
.catch(this.$fhcAlert.handleSystemError);
}
},
mounted() {
this.loadTree();
},
template: `
<div class="p-2">
<menu-switch v-model="menu"></menu-switch>
<tree-filter class="mt-2"
:advanced="advanced"
@filter="onFilter"
@criteria="onCriteria"></tree-filter>
<div class="mt-2 mb-2">
<button class="btn btn-sm btn-outline-secondary me-1"
@click="createEntry(null)">
{{ $p.t('cms/neuenEintragHinzufuegen') }}
</button>
<button class="btn btn-sm btn-outline-secondary"
:disabled="activeContentId == null"
@click="createEntry(activeContentId)">
{{ $p.t('cms/neuenChildEintragHinzufuegen') }}
</button>
</div>
<div v-if="isFlat && !loading" class="d-flex align-items-center gap-2 mb-2">
<div v-if="active" class="form-text m-0">
<i v-if="metaLoading" class="fa-solid fa-spinner fa-spin me-1"></i>
{{ $p.t('cms/nTreffer', [visibleNodes.length]) }}
</div>
<button type="button"
class="btn btn-sm ms-auto"
:class="showHierarchy ? 'btn-secondary' : 'btn-outline-secondary'"
@click="toggleHierarchy">
<i class="fa-solid fa-fw"
:class="ancestorsLoading ? 'fa-spinner fa-spin' : 'fa-sitemap'"></i>
{{ $p.t('cms/hierarchieAnzeigen') }}
</button>
</div>
<div v-if="loading" class="text-center text-muted py-3">
<i class="fa-solid fa-spinner fa-spin"></i>
</div>
<div v-else-if="visibleNodes?.length === 0" class="text-muted py-2">
{{ (filter || active) ? $p.t('cms/keineTreffer') : $p.t('cms/keineEintraege') }}
</div>
<ul v-else class="list-unstyled mb-0">
<cms-tree-node
v-for="node in visibleNodes"
:key="node.content_id"
:node="node"
:active-content-id="activeContentId"
:expanded-keys="expandedKeys"
:flat="isFlat && !showPaths"
:meta="meta"
:metric="metric"
:ancestors="pathFor(node.content_id)"
></cms-tree-node>
</ul>
</div>
`
};
@@ -1,35 +0,0 @@
export default {
name: 'CmsMenuSwitch',
props: {
modelValue: { type: String, default: 'content' }
},
emits: ['update:modelValue'],
methods: {
select(value) {
localStorage.setItem('cms/menu', value);
this.$emit('update:modelValue', value);
}
},
mounted() {
// The legacy code keeps this choice in $_SESSION['cms/menu']. The Vue client uses
// localStorage. The effect for the editor is the same.
const stored = localStorage.getItem('cms/menu');
if ((stored === 'content' || stored === 'news') && stored !== this.modelValue) {
this.$emit('update:modelValue', stored);
}
},
template: `
<div class="btn-group btn-group-sm w-100" role="group">
<button type="button" class="btn"
:class="modelValue === 'content' ? 'btn-primary' : 'btn-outline-secondary'"
@click="select('content')">
{{ $p.t('cms/menuContent') }}
</button>
<button type="button" class="btn"
:class="modelValue === 'news' ? 'btn-primary' : 'btn-outline-secondary'"
@click="select('news')">
{{ $p.t('cms/menuNews') }}
</button>
</div>
`
};
-209
View File
@@ -1,209 +0,0 @@
import ApiCmsAdmin from '../../../api/factory/cmsadmin.js';
// Empty means "do not narrow by this field". Keep the inputs as strings, the tree parses.
function emptyCriteria() {
return {
template_kurzbz: '',
mine: false,
updatedFrom: '',
updatedTo: '',
createdFrom: '',
createdTo: '',
minChildren: '',
maxChildren: '',
minLength: '',
maxLength: '',
emptyOnly: false,
sort: ''
};
}
export { emptyCriteria };
export default {
name: 'CmsTreeFilter',
props: {
// The news menu is a flat list of its own. It carries none of these fields.
advanced: { type: Boolean, default: true }
},
emits: ['filter', 'criteria'],
data() {
return {
text: '',
open: false,
templates: [],
criteria: emptyCriteria()
};
},
computed: {
// Counts the narrowing fields only. The sort order hides no entry.
activeCount() {
const c = this.criteria;
let count = 0;
if (c.template_kurzbz) count++;
if (c.mine) count++;
if (c.updatedFrom || c.updatedTo) count++;
if (c.createdFrom || c.createdTo) count++;
if (c.minChildren !== '' || c.maxChildren !== '') count++;
if (c.minLength !== '' || c.maxLength !== '') count++;
if (c.emptyOnly) count++;
return count;
}
},
methods: {
submit() {
this.$emit('filter', this.text);
},
toggle() {
this.open = !this.open;
if (this.open && !this.templates.length) this.loadTemplates();
},
loadTemplates() {
this.$api
.call(ApiCmsAdmin.getTemplates())
.then(result => { this.templates = result.data || []; })
.catch(this.$fhcAlert.handleSystemError);
},
reset() {
this.text = '';
// The watcher emits the cleared criteria.
this.criteria = emptyCriteria();
this.$emit('filter', '');
}
},
watch: {
// v-model writes before the watcher runs, so the emitted object is complete.
criteria: {
handler(criteria) { this.$emit('criteria', { ...criteria }); },
deep: true
}
},
created() {
this.sortOptions = [
{ value: '', phrase: 'cms/sortHierarchie' },
{ value: 'titel', phrase: 'cms/titel' },
{ value: 'updateamum', phrase: 'cms/sortGeaendert' },
{ value: 'insertamum', phrase: 'cms/sortErstellt' },
{ value: 'childcount', phrase: 'cms/sortChilds' },
{ value: 'contentlength', phrase: 'cms/sortInhaltslaenge' }
];
},
template: `
<div>
<div class="input-group input-group-sm">
<input type="text"
class="form-control"
:placeholder="$p.t('cms/filterPlatzhalter')"
v-model="text"
@keydown.enter="submit">
<button class="btn btn-outline-secondary" type="button" @click="submit">
<i class="fa-solid fa-magnifying-glass"></i>
</button>
</div>
<button v-if="advanced"
type="button"
class="btn btn-sm btn-link px-0 text-decoration-none"
@click="toggle">
<i class="fa-solid fa-fw" :class="open ? 'fa-caret-down' : 'fa-caret-right'"></i>
{{ $p.t('cms/filterErweitert') }}
<span v-if="activeCount" class="badge bg-primary ms-1">{{ activeCount }}</span>
</button>
<div v-if="advanced && open" class="border rounded p-2 mb-2">
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/vorlage') }}</label>
<select class="form-select form-select-sm"
v-model="criteria.template_kurzbz">
<option value="">{{ $p.t('cms/alleVorlagen') }}</option>
<option v-for="t in templates"
:key="t.template_kurzbz"
:value="t.template_kurzbz">{{ t.bezeichnung }}</option>
</select>
</div>
<div class="form-check mb-2">
<input type="checkbox"
class="form-check-input"
id="cms-filter-mine"
v-model="criteria.mine">
<label class="form-check-label" for="cms-filter-mine">
{{ $p.t('cms/nurEigeneBearbeitungen') }}
</label>
</div>
<div class="form-check mb-2">
<input type="checkbox"
class="form-check-input"
id="cms-filter-empty"
v-model="criteria.emptyOnly">
<label class="form-check-label" for="cms-filter-empty">
{{ $p.t('cms/nurLeereInhalte') }}
</label>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortGeaendert') }}</label>
<div class="input-group input-group-sm">
<input type="date" class="form-control"
:title="$p.t('cms/vonDatum')"
v-model="criteria.updatedFrom">
<input type="date" class="form-control"
:title="$p.t('cms/bisDatum')"
v-model="criteria.updatedTo">
</div>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortErstellt') }}</label>
<div class="input-group input-group-sm">
<input type="date" class="form-control"
:title="$p.t('cms/vonDatum')"
v-model="criteria.createdFrom">
<input type="date" class="form-control"
:title="$p.t('cms/bisDatum')"
v-model="criteria.createdTo">
</div>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortChilds') }}</label>
<div class="input-group input-group-sm">
<input type="number" min="0" class="form-control"
:placeholder="$p.t('cms/mindestens')"
v-model="criteria.minChildren">
<input type="number" min="0" class="form-control"
:placeholder="$p.t('cms/hoechstens')"
v-model="criteria.maxChildren">
</div>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortInhaltslaenge') }}</label>
<div class="input-group input-group-sm">
<input type="number" min="0" class="form-control"
:placeholder="$p.t('cms/mindestens')"
v-model="criteria.minLength">
<input type="number" min="0" class="form-control"
:placeholder="$p.t('cms/hoechstens')"
v-model="criteria.maxLength">
</div>
</div>
<div class="mb-2">
<label class="form-label mb-1">{{ $p.t('cms/sortierung') }}</label>
<select class="form-select form-select-sm"
v-model="criteria.sort">
<option v-for="o in sortOptions" :key="o.value" :value="o.value">
{{ $p.t(o.phrase) }}
</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="reset">
{{ $p.t('cms/filterZuruecksetzen') }}
</button>
</div>
</div>
`
};
@@ -1,84 +0,0 @@
import ApiCms from '../../api/factory/cms.js';
/**
* Content component: lists the child pages of a content as a menu.
*
* It replaces a hand written box of content.php links. Those links carry a fixed
* content_id and break when a page moves. This list follows tbl_contentchild, which the
* Kindelemente tab of the CMS admin already maintains.
*
* Without the content-id property the menu shows the children of the page it sits on.
* With it, the menu shows the children of another page. The second form exists because
* the same menu appears on several pages today.
*/
export default {
name: 'ContentchildMenu',
inject: {
contentcomponentContentId: { default: null }
},
props: {
contentId: {
type: [Number, String],
default: null
}
},
data() {
return {
kinder: [],
loading: true,
failed: false
};
},
computed: {
sprache() {
return this.$p.user_language.value;
},
// The property wins. Without it the menu takes the page it sits on.
// Vue unwraps an injected ref on the instance, so no .value here.
quelle() {
return this.contentId || this.contentcomponentContentId;
}
},
methods: {
load() {
if (!this.quelle)
{
this.loading = false;
return;
}
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getContentChilds(this.quelle, this.sprache))
.then(res => { this.kinder = res.data || []; })
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
quelle() { this.load(); },
// The titles come per language, so a language switch needs a new request.
sprache() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-contentchildmenu">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerMenue') }}
</div>
<!-- An empty menu renders nothing. A reader has no use for the note that a
page has no children, and the group rule may have removed every entry. -->
<ul v-else-if="kinder.length" class="list-unstyled mb-0">
<li v-for="kind in kinder" :key="kind.content_id" class="mb-1">
<router-link :to="{ name: 'Content', params: { content_id: kind.content_id } }">
{{ kind.titel }}
</router-link>
</li>
</ul>
</div>
`
};
@@ -1,78 +0,0 @@
import { components } from './components.js';
import { scanContentcomponents } from './scan.js';
/**
* Renders CMS content HTML and mounts the content components it declares.
*
* Use it in place of a plain `<div v-html="content">`. It owns the root element, the
* change watch and the Teleports, so a host site needs no ref and no lifecycle code.
*
* Teleport, not a separate createApp: the components stay in this app's component tree.
* They therefore share the one $api instance, the one $p instance, the router and the
* provided keys. A createApp per marker would build a second of each.
*/
export default {
name: 'ContentcomponentHost',
props: {
content: {
type: String,
default: ''
},
// The page this content belongs to. contentchild-menu needs it when the editor
// gave no explicit id.
contentId: {
type: [Number, String],
default: null
}
},
provide() {
// Vue.computed keeps the value live, the same way Cis.js provides its keys.
return {
contentcomponentContentId: Vue.computed(() => this.contentId)
};
},
data() {
return {
markers: []
};
},
methods: {
rescan() {
this.markers = scanContentcomponents(this.$refs.root);
},
resolve(name) {
return components[name];
}
},
watch: {
// Vue replaces the whole v-html subtree when the string changes. A marker that
// still points at a removed node makes Teleport write into a detached element,
// and the component vanishes without an error. Drop the markers first, let the
// new HTML render, then scan again.
content() {
this.markers = [];
this.$nextTick(this.rescan);
}
},
// A failing content component must not take down the content around it.
errorCaptured(err, instance, info) {
console.error('Contentcomponent failed:', info, err);
return false;
},
mounted() {
// The components below carry their texts in category cms. $p.t would fetch it by
// itself on the first miss and fill in when it arrives, which shows an empty line
// for that moment. One request here from the single gateway spares that.
this.$p.loadCategory('cms');
this.rescan();
},
beforeUnmount() {
this.markers = [];
},
template: /*html*/ `
<div ref="root" v-html="content"></div>
<Teleport v-for="marker in markers" :key="marker.key" :to="marker.el">
<component :is="resolve(marker.name)" v-bind="marker.props"></component>
</Teleport>
`
};
@@ -1,56 +0,0 @@
import ApiCms from '../../api/factory/cms.js';
import DokumentListe from './DokumentListe.js';
/**
* Content component: named DMS documents, in the order the editor gave.
*/
export default {
name: 'DmsDokumente',
components: {
DokumentListe
},
props: {
dmsIds: {
type: String,
required: true
}
},
data() {
return {
dokumente: [],
loading: true,
failed: false
};
},
methods: {
load() {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getDmsDokumente(this.dmsIds))
.then(res => { this.dokumente = res.data || []; })
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
dmsIds() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-dmsdokumente">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerDokumente') }}
</div>
<!-- An empty list renders nothing. Every named document may have dropped out
through the category rule, and that is not the reader's business. -->
<dokument-liste
v-else-if="dokumente.length"
:dokumente="dokumente"
></dokument-liste>
</div>
`
};
@@ -1,73 +0,0 @@
import ApiCms from '../../api/factory/cms.js';
import DokumentListe from './DokumentListe.js';
/**
* Content component: the published documents of one DMS category.
*
* The low maintenance variant. It lists every document of the category whose newest
* version carries cis_suche, so a replaced or added document appears without an edit in
* the CMS. Use dms-dokumente when the editor must pick single files.
*/
export default {
name: 'DmsListe',
components: {
DokumentListe
},
props: {
kategorieKurzbz: {
type: String,
required: true
},
// 0 lists the category itself, which is what a marker without the attribute
// means. Higher takes that many levels of sub categories with it.
tiefe: {
type: Number,
default: 0
}
},
data() {
return {
dokumente: [],
zugriff: true,
loading: true,
failed: false
};
},
methods: {
load() {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getDmsKategorie(this.kategorieKurzbz, this.tiefe))
.then(res => {
this.zugriff = res.data.zugriff;
this.dokumente = res.data.dokumente || [];
})
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
kategorieKurzbz() { this.load(); },
tiefe() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-dmsliste">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerDokumente') }}
</div>
<!-- A reader without the entitlement sees nothing at all, not a hint that
something is hidden here. -->
<template v-else-if="zugriff">
<div v-if="!dokumente.length" class="text-muted">
{{ $p.t('cms/ccKeineDokumente') }}
</div>
<dokument-liste v-else :dokumente="dokumente"></dokument-liste>
</template>
</div>
`
};
@@ -1,59 +0,0 @@
// Mimetype to Font Awesome 6 icon. The first match wins.
const ICONS = [
[/pdf/, 'fa-file-pdf'],
[/word|opendocument\.text/, 'fa-file-word'],
[/excel|sheet|csv/, 'fa-file-excel'],
[/powerpoint|presentation/, 'fa-file-powerpoint'],
[/^image\//, 'fa-file-image'],
[/zip|compressed|tar|rar|7z/, 'fa-file-zipper'],
[/^video\//, 'fa-file-video'],
[/^audio\//, 'fa-file-audio'],
[/^text\//, 'fa-file-lines']
];
/**
* Renders a list of DMS documents as download links.
*
* It fetches nothing. dms-liste and dms-dokumente both fill it, so a document reads the
* same whether the editor named a category or single ids.
*/
export default {
name: 'DokumentListe',
props: {
dokumente: {
type: Array,
default: () => []
}
},
methods: {
icon(mimetype) {
const typ = (mimetype || '').toLowerCase();
const treffer = ICONS.find(([muster]) => muster.test(typ));
return treffer ? treffer[1] : 'fa-file';
},
href(dokument) {
return FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cms/dms.php?id=' + dokument.dms_id;
},
datum(wert) {
if (!wert) return '';
const teile = String(wert).slice(0, 10).split('-');
return teile.length === 3 ? teile[2] + '.' + teile[1] + '.' + teile[0] : '';
}
},
template: /*html*/ `
<ul class="list-unstyled mb-0">
<li v-for="dokument in dokumente" :key="dokument.dms_id" class="mb-2">
<a :href="href(dokument)" target="_blank" rel="noopener">
<i class="fa-regular" :class="icon(dokument.mimetype)"></i>
{{ dokument.name }}
</a>
<span v-if="dokument.geaendert" class="text-muted small ms-2">
{{ datum(dokument.geaendert) }}
</span>
<div v-if="dokument.beschreibung" class="text-muted small">
{{ dokument.beschreibung }}
</div>
</li>
</ul>
`
};
@@ -1,85 +0,0 @@
import ApiCms from '../../api/factory/cms.js';
import PersonBlock from './PersonBlock.js';
/**
* Content component: every person assigned to an organisation unit.
*
* The low maintenance variant. A new colleague appears without an edit in the CMS, and a
* leaving one disappears. The price is that the editor cannot steer who is shown. Use
* person-block when that control matters.
*
* The function label per person comes from tbl_benutzerfunktion.bezeichnung,
* and falls back to tbl_funktion.beschreibung.
*/
export default {
name: 'OePersonen',
components: {
PersonBlock
},
props: {
oeKurzbz: {
type: String,
required: true
},
foto: {
type: Boolean,
default: false
},
// 0 lists the unit itself, which is what a marker without the attribute means.
// Higher takes that many levels of sub units with it.
tiefe: {
type: Number,
default: 0
}
},
data() {
return {
personen: [],
loading: true,
failed: false
};
},
methods: {
load() {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getOePersonen(this.oeKurzbz, this.foto, this.tiefe))
.then(res => { this.personen = res.data || []; })
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
oeKurzbz() { this.load(); },
foto() { this.load(); },
tiefe() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-oepersonen">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerPersonen') }}
</div>
<div v-else-if="!personen.length" class="text-muted">
{{ $p.t('cms/ccKeinePersonen') }}
</div>
<template v-else>
<person-block
v-for="person in personen"
:key="person.uid"
:uid="person.uid"
:name="person.name"
:funktion="person.funktion"
:telefon="person.telefon"
:email="person.email"
:ort="person.ort"
:foto="person.foto"
></person-block>
</template>
</div>
`
};
@@ -1,53 +0,0 @@
/**
* Renders one person the way the teambox blocks do today: name, function, phone, room,
* mail, and an optional photo.
*
* It fetches nothing. person-block and oe-personen both fill it, so one person reads the
* same whether the editor named a UID or an organisation unit.
*/
export default {
name: 'PersonBlock',
props: {
uid: String,
name: String,
funktion: String,
telefon: String,
email: String,
ort: String,
foto: String
},
computed: {
// tel: takes digits and a leading plus.
telefonLink() {
return this.telefon ? 'tel:' + this.telefon.replace(/[^\d+]/g, '') : null;
},
fotoQuelle() {
return this.foto ? 'data:image/png;base64,' + this.foto : null;
}
},
template: /*html*/ `
<div class="fhc-contentcomponent-person mb-3">
<img
v-if="fotoQuelle"
:src="fotoQuelle"
alt=""
class="mb-2"
style="width: 110px; height: auto; object-fit: scale-down;"
>
<div>
<router-link v-if="uid" :to="{ name: 'ProfilView', params: { uid: uid } }">
{{ name }}
</router-link>
<span v-else>{{ name }}</span>
</div>
<div v-if="funktion">{{ funktion }}</div>
<div v-if="telefon">
T: <a :href="telefonLink">{{ telefon }}</a>
</div>
<div v-if="ort">R: {{ ort }}</div>
<div v-if="email">
E: <a :href="'mailto:' + email">{{ email }}</a>
</div>
</div>
`
};
@@ -1,77 +0,0 @@
import ApiCms from '../../api/factory/cms.js';
import PersonBlock from './PersonBlock.js';
/**
* Content component: one person, named by UID.
*
* The flexible variant. The editor decides who appears and in which order by placing
* several markers. Use oe-personen instead when the list should follow the organizational structure.
*
* The function label comes from the marker, not from the database, because the database
* name does not always fit. It needs no phrase key: the CMS holds one content per
* language, so the German page carries the German label and the English page the English
* one.
*/
export default {
name: 'PersonKontakt',
components: {
PersonBlock
},
props: {
uid: {
type: String,
required: true
},
funktion: {
type: String,
default: ''
},
foto: {
type: Boolean,
default: false
}
},
data() {
return {
person: null,
loading: true,
failed: false
};
},
methods: {
load() {
this.loading = true;
this.failed = false;
this.$api
.call(ApiCms.getPerson(this.uid, this.foto))
.then(res => { this.person = res.data; })
.catch(() => { this.failed = true; })
.finally(() => { this.loading = false; });
}
},
watch: {
uid() { this.load(); },
foto() { this.load(); }
},
created() {
this.load();
},
template: /*html*/ `
<div class="fhc-contentcomponent-personkontakt">
<div v-if="loading" class="text-muted">...</div>
<div v-else-if="failed" class="alert alert-warning py-2">
{{ $p.t('cms/ccLadefehlerPerson') }}
</div>
<person-block
v-else-if="person"
:uid="person.uid"
:name="person.name"
:funktion="funktion"
:telefon="person.telefon"
:email="person.email"
:ort="person.ort"
:foto="person.foto"
></person-block>
</div>
`
};

Some files were not shown because too many files have changed in this diff Show More