Commit bb044284 authored by Carsten  Rose's avatar Carsten Rose

Merge remote-tracking branch 'origin/master'

parents d5312e8b f0ee6ef9
Pipeline #2168 failed with stages
in 2 minutes and 28 seconds
......@@ -37,6 +37,34 @@ Bug Fixes
^^^^^^^^^
Version 19.7.1
--------------
Date: 17.07.2019
Notes
^^^^^
* TWIG integration
Features
^^^^^^^^
* TWIG:
* Add Twig Extension to parse QFQ Links
* Pass assoc-array of query result as context
* allow to pass template as string
* Database.php: extend function sql error message
* #8179 / extraButtonLock and extraButtonPassword might be specified without a value or with 0 or with 1.
Bug Fixes
^^^^^^^^^
* Database.php: fix CodeException
* Fabric: Readonly now displays annotations again.
Version 19.7.0
--------------
......@@ -51,8 +79,8 @@ Notes
Features
^^^^^^^^
* #7284 tablesorter save sort order
* #8592 Fixed button display, added Alert instead of disabled button.
* #7284 / tablesorter save sort order
* #8592 / Fixed button display, added Alert instead of disabled button.
* #8204 / position required mark
* Excel.php: Delete superficial autoloader require
* Cleanup: hashPassword.php moved from Api to External.
......
......@@ -53,12 +53,12 @@ Neue Versionsnummer
* Update the version number in this document (topic 6)
* Commit & Push new version changes to master branch:
New version 19.7.0
New version 19.7.1
6) **New Tag**:
git tag v19.7.0
git push -u origin v19.7.0
git tag v19.7.1
git push -u origin v19.7.1
7) Tickets:
* Schliessen und der QFQ Version zuweisen.
......
......@@ -732,6 +732,8 @@ Insert one or more QFQ content elements on a Typo3 page. Specify column and lang
The title of the QFQ content element will not be rendered on the frontend. It's only visible to the webmaster in the
backend for orientation.
.. _qfq_keywords:
QFQ Keywords (Bodytext)
^^^^^^^^^^^^^^^^^^^^^^^
......@@ -789,6 +791,8 @@ QFQ Keywords (Bodytext)
+-------------------+---------------------------------------------------------------------------------+
| <level>.sql | SQL Query |
+-------------------+---------------------------------------------------------------------------------+
| <level>.twig | Twig Template |
+-------------------+---------------------------------------------------------------------------------+
| <level>.althead | If <level>.sql is empty, these token will be rendered. |
+-------------------+---------------------------------------------------------------------------------+
| <level>.altsql | If <level>.sql is empty, these query will be fired. No sub queries. |
......@@ -796,6 +800,8 @@ QFQ Keywords (Bodytext)
+-------------------+---------------------------------------------------------------------------------+
| <level>.content | | *show* (default): content of current and sub level are directly shown. |
| | | *hide*: content of current and sub levels are stored and not shown. |
| | | *hideLevel*: content of current and sub levels are stored and only sub levels |
| | | are shown. |
| | | *store*: content of current and sub levels are stored and shown. |
| | | To retrieve the content: `{{<level>.line.content}}`. See `syntax-of-report`_ |
+-------------------+---------------------------------------------------------------------------------+
......@@ -5172,7 +5178,7 @@ A simple example
Assume that the database has a table person with columns firstName and lastName. To create a simple list of all persons, we can do the following::
10.sql = SELECT id AS pId, CONCAT(firstName, " ", lastName, " ") AS name FROM person
10.sql = SELECT firstName, lastName FROM person
The '10' indicates a *root level* of the report (see section `Structure`_). The expresssion '10.sql' defines a SQL query
for the specific level. When the query is executed, it will return a result having one single column name containing first and last name
......@@ -5180,21 +5186,65 @@ separated by a space character.
The HTML output, displayed on the page, resulting from only this definition, could look as follows::
John DoeJane MillerFrank Star
JohnDoeJaneMillerFrankStar
I.e., QFQ will simply output the content of the SQL result row after row for each single level.
However, we can modify (wrap) the output by setting the values of various keys for each level: 10.rsep=<br/> for example
tells QFQ to separate the rows of the result by a HTML-line break. The final result in this case is::
Format output: mix style and content
""""""""""""""""""""""""""""""""""""
10.sql = SELECT id AS personId, CONCAT(firstName, " ", lastName, " ") AS name FROM person
10.rsep = <br>
Variant 1: pure SQL
;;;;;;;;;;;;;;;;;;;
To format the upper example, just create additional columns::
10.sql = SELECT firstName, ", ", lastName, "<br>" FROM person
HTML output::
Doe, John<br>
Miller, Jane<br>
Star, Frank<br>
Variant 2: SQL plus QFQ helper
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
QFQ provides several helper functions to wrap rows and columns, or to separate them. In this example 'fsep'='field
separate and 'rend' = row end:
10.sql = SELECT firstName, lastName FROM person
10.fsep = ', '
10.rend = <br>
HTML output::
Doe, John<br>
Miller, Jane<br>
Star, Frank<br>
Check out all QFQ helpers under qfq_keywords_.
Due to mixing style and content, this becomes harder to maintain with more complex layout.
Format output: separate style and content
"""""""""""""""""""""""""""""""""""""""""
The result of the query can be passed to the `Twig template engine <https://twig.symphony.com/>`_
in order to fill a template with the data.::
10.sql = SELECT firstName, lastName FROM person
10.twig = {% for row in result %}
{{ row.lastName }}, {{ row.firstName }<br />
{% endfor %}
HTML output::
John Doe<br>Jane Miller<br>Frank Star
Doe, John<br>
Miller, Jane<br>
Star, Frank<br>
Check out using-twig_.
.. _`syntax-of-report`:
......@@ -5259,8 +5309,103 @@ Processing of the resulting rows and columns:
There are extensive ways to wrap columns and rows. See :ref:`wrapping-rows-and-columns`
.. _`using-twig`:
Using Twig
----------
How to write Twig templates is documented by the `Twig Project <https://twig.symphony.com/>`_.
QFQ passes the result of a given query to the corresponding Twig template using the Twig variable
`result`. So templates need to use this variable to access the result of a query.
Specifying a Template
^^^^^^^^^^^^^^^^^^^^^
By default the string passed to the **twig**-key is used as template directly,
as shown in the simple example above::
10.twig = {% for row in result %}
{{ row.lastName }}, {{ row.firstName }<br />
{% endfor %}
However, if the string starts with **file:**, the template is read from the
file specified.::
10.twig = file:table_sortable.html.twig
The file is searched relative to *<site path>* and if the file is not found there, it's searched relative to
QFQ's *twig_template* folder where the included base templates are stored.
The following templates are available:
``tables/default.html.twig``
A basic table with column headers, sorting and column filters using tablesorter and bootstrap.
``tables/single_vertical.html.twig``
A template to display the values of a single record in a vertical table.
Links
^^^^^
The link syntax described in column-link_ is available inside Twig templates
using the `qfqlink` filter::
{{ "u:http://www.example.com" | qfqlink }}
will render a link to *http://www.example.com*.
Available Store Variables
^^^^^^^^^^^^^^^^^^^^^^^^^
QFQ also provides access to the following stores via the template context.
* record
* sip
* typo3
* user
* system
All stores are accessed using their lower case name as attribute of the
context variable `store`. The active Typo3 front-end user is therefore available as::
{{ store.typo3.feUser }}
A realistic example
^^^^^^^^^^^^^^^^^^^
The following block shows a more realistic example of a QFQ report.
*10.sql* selects all users who have been assigned files in our file tracker.
.. TODO use content = hide instead of _user once this is implemented
*10.10* then selects all files belonging to this user, prints the username as header
and then displays the files in a nice table with links to the files.
::
10.sql = SELECT assigned_to AS _user FROM file_tracker
WHERE assigned_to IS NOT NULL
GROUP BY _user
ORDER BY _user
10.10.sql = SELECT id, path_scan FROM file_tracker
WHERE assigned_to = '{{user:R}}'
10.10.twig = <h2>{{ store.record.user }}</h2>
<table id="file-list" class="table table-hover tablesorter">
<thead><tr><th>ID</th><th>File</th></tr></thead>
<tbody>
{% for row in result %}
<tr>
<td>{{ row.id }}</td>
<td>{{ ("d:|M:pdf|s|t:"~ row.path_scan ~"|F:" ~ row.path_scan ) | qfqlink }}</td>
</tr>
{% endfor %}
</tbody>
</table>
Debug the bodytext
------------------
The parsed bodytext could be displayed by activating 'showDebugInfo' (:ref:`debug`) and specifying
::
......@@ -5548,6 +5693,11 @@ Processing of columns in the SQL result
Special column names
--------------------
.. note::
Twig: respect that the 'special column name'-columns are rendered before Twig becomes active. The recommended
way by using Twig is *not to use* special column names at all. Use the Twig version *qfqLink*.
QFQ typically don't care about the content of any SQL-Query - it just copy the content to the output (=Browser).
One exception are columns, whose name starts with '_'. E.g.::
......@@ -6551,6 +6701,20 @@ Example::
In case 'Note.title' contains a '|' (like 'fruit | numbers'), it will confuse the '... AS _link' class. Therefore it's
necessary to 'escape' (adding a '\' in front of the problematic character) the bar which is done by using `QBAR()`.
.. _qnl2br:
QNL2BR: Convert newline to HTML '<br>'
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
The SQL function QNL2BR(text) replaces `LF` or `CR/LF` by `<br>`. This can be used for data (containing LF) to output
on a HTML page with correctly displayed linefeed.
Example::
10.sql = SELECT QNL2BR(Note.title) FROM Note  
One possibility how `LF` comes into the database is with form elements of type `textarea` if the user presses `enter` inside.
.. _qmore-truncate-long-text:
QMORE: Truncate Long Text - more/less
......
......@@ -37,6 +37,34 @@ Bug Fixes
^^^^^^^^^
Version 19.7.1
--------------
Date: 17.07.2019
Notes
^^^^^
* TWIG integration
Features
^^^^^^^^
* TWIG:
* Add Twig Extension to parse QFQ Links
* Pass assoc-array of query result as context
* allow to pass template as string
* Database.php: extend function sql error message
* #8179 / extraButtonLock and extraButtonPassword might be specified without a value or with 0 or with 1.
Bug Fixes
^^^^^^^^^
* Database.php: fix CodeException
* Fabric: Readonly now displays annotations again.
Version 19.7.0
--------------
......@@ -51,8 +79,8 @@ Notes
Features
^^^^^^^^
* #7284 tablesorter save sort order
* #8592 Fixed button display, added Alert instead of disabled button.
* #7284 / tablesorter save sort order
* #8592 / Fixed button display, added Alert instead of disabled button.
* #8204 / position required mark
* Excel.php: Delete superficial autoloader require
* Cleanup: hashPassword.php moved from Api to External.
......
......@@ -21,8 +21,8 @@
; you can use in 'conf.py'
project = QFQ - Quick Form Query
version = 19.7
release = 19.7.0
version = 19.7
release = 19.7.1
t3author = Carsten Rose
copyright = since 2017 by the author
......
......@@ -274,6 +274,7 @@ const ERROR_UNKNOWN_TOKEN = 1407;
const ERROR_TOO_FEW_PARAMETER_FOR_SENDMAIL = 1408;
const ERROR_TOO_MANY_PARAMETER = 1409;
const ERROR_INVALID_SAVE_PDF_FILENAME = 1410;
const ERROR_TWIG_COLUMN_NOT_UNIQUE = 1411;
// Upload
const ERROR_UPLOAD = 1500;
......@@ -1461,6 +1462,7 @@ const SENDMAIL_TOKEN_ATTACHMENT_PAGE = 'p';
// Report, BodyText
const TOKEN_SQL = 'sql';
const TOKEN_TWIG = 'twig';
const TOKEN_HEAD = 'head';
const TOKEN_ALT_HEAD = 'althead';
const TOKEN_ALT_SQL = 'altsql';
......@@ -1483,11 +1485,12 @@ const TOKEN_DEBUG_BODYTEXT = TYPO3_DEBUG_SHOW_BODY_TEXT;
const TOKEN_DB_INDEX = F_DB_INDEX;
const TOKEN_CONTENT = 'content';
const TOKEN_VALID_LIST = 'sql|head|althead|altsql|tail|shead|stail|rbeg|rend|renr|rsep|fbeg|fend|fsep|fskipwrap|rbgd|debug|form|r|debugShowBodyText|dbIndex|sqlLog|sqlLogMode|content';
const TOKEN_VALID_LIST = 'sql|twig|head|althead|altsql|tail|shead|stail|rbeg|rend|renr|rsep|fbeg|fend|fsep|fskipwrap|rbgd|debug|form|r|debugShowBodyText|dbIndex|sqlLog|sqlLogMode|content';
const TOKEN_COLUMN_CTRL = '_';
const TOKEN_CONTENT_STORE = 'store';
const TOKEN_CONTENT_HIDE_LEVEL = 'hideLevel';
const TOKEN_CONTENT_HIDE = 'hide';
const TOKEN_CONTENT_SHOW = 'show';
......
......@@ -971,7 +971,7 @@ class Database {
$executed = $this->mysqli->multi_query($sqlStatements);
if (false === $executed) {
$errorMsg[ERROR_MESSAGE_TO_USER] = 'SQL Error.';
$errorMsg[ERROR_MESSAGE_TO_DEVELOPER] = "Error playing multi query: " . $sqlStatements;
$errorMsg[ERROR_MESSAGE_TO_DEVELOPER] = "Error playing multi query: " . $this->mysqli->error . "\n\nSQL Query:\n\n" . $sqlStatements;
throw new \CodeException(json_encode($errorMsg), ERROR_PLAY_SQL_MULTIQUERY);
}
......
......@@ -190,11 +190,13 @@ class DatabaseUpdate {
"in " . QFQ_FUNCTION_SQL . ", this usually leads to errors when trying to execute it on the database.";
throw new \DbException(json_encode($errorMsg), ERROR_PLAY_SQL_FILE);
}
$this->db->playMultiQuery($query);
try {
$this->db->playMultiQuery($query);
$functionsHashTest = $this->db->sql('SELECT GETFUNCTIONSHASH() AS res;', ROW_EXPECT_1)['res'];
} catch (\DbException $e) {
$functionsHashTest = null;
} catch (\CodeException $e) {
$functionsHashTest = null;
}
if ($functionHash !== null AND $functionsHashTest === $functionHash) {
return $functionHash;
......@@ -203,9 +205,9 @@ class DatabaseUpdate {
$errorMsg[ERROR_MESSAGE_TO_DEVELOPER] =
"Failed to play " . QFQ_FUNCTION_SQL . ", probably not enough permissions for the qfq mysql user. " .
"Possible solutions: <ul>" .
'<li>Grant Super privileges to qfq mysql user temporarily.</li>' .
'<li>Grant SUPER, CREATE ROUTINE, ALTER ROUTINE privileges to qfq mysql user temporarily.</li>' .
'<li>Play the following file manually on the database: ' .
'<a href="typo3conf/ext/qfq/Classes/Sql/' . QFQ_FUNCTION_SQL . '">typo3conf/ext/qfq/Classes/Sql/' . QFQ_FUNCTION_SQL . '</a></li>' .
'<a href="typo3conf/ext/qfq/Classes/Sql/' . QFQ_FUNCTION_SQL . '">typo3conf/ext/qfq/Classes/Sql/' . QFQ_FUNCTION_SQL . '</a><br>and grant the qfq mysql user execution privileges on the sql functions.</li>' .
'<li><a href="?' . http_build_query(array_merge($_GET, array(ACTION_FUNCTION_UPDATE => ACTION_FUNCTION_UPDATE_NEXT_UPDATE))) . '">Click here</a> to skip the sql functions update until next qfq release update</li>' .
'<li><a href="?' . http_build_query(array_merge($_GET, array(ACTION_FUNCTION_UPDATE => ACTION_FUNCTION_UPDATE_NEVER))) . '">Click here</a> to skip the sql functions update forever</li>' .
'</ul>' .
......
......@@ -462,6 +462,7 @@ class Report {
//condition2: full_level == Superlevel (but at the length of the Superlevel)
while ($counter < count($this->indexArray) && $full_super_level == substr($fullLevel, 0, strlen($full_super_level))) {
$contentLevel = '';
$contentSubqueries = '';
//True: The cur_level is a subquery -> continue
if ($cur_level != count($this->indexArray[$counter])) {
......@@ -489,7 +490,7 @@ class Report {
$this->store->setVar(SYSTEM_SQL_FINAL, $sql, STORE_SYSTEM);
//Execute SQL. All errors have been already catch'd.
//Execute SQL. All errors have been already catched.
unset($result);
$result = $this->db->sql($sql, ROW_KEYS, array(), '', $keys, $stat);
......@@ -502,12 +503,22 @@ class Report {
$this->variables->resultArray[$fullLevel . ".line."][LINE_COUNT] = is_array($result) ? 1 : 0;
$this->variables->resultArray[$fullLevel . ".line."][LINE_INSERT_ID] = $stat[DB_INSERT_ID] ?? 0;
/////////////////////////////////
// Render SHEAD and HEAD //
/////////////////////////////////
$contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_SHEAD]);
// HEAD: If there is at least one record, do 'head'.
if ($rowTotal > 0) {
$contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_HEAD]);
}
///////////////////////
// Render rows //
///////////////////////
if (is_array($result)) {
// Prepare row alteration
......@@ -517,6 +528,7 @@ class Report {
$arrRbgd[] = '';
}
// Prepare skip wrapping of indexed columns
$fSkipWrap = array();
if ('' != ($str = ($this->frArray[$fullLevel . "." . TOKEN_FSKIPWRAP]) ?? '')) {
$str = str_replace(' ', '', $str);
......@@ -530,7 +542,7 @@ class Report {
$rowIndex = 0;
foreach ($result as $row) {
// record number counter
// increment record number counter
$this->variables->resultArray[$fullLevel . ".line."][LINE_COUNT] = ++$rowIndex;
// replace {{<level>.line.count}} and {{<level>.line.total}} in __result__, if the variables specify their own full_level. This can't be replaced before firing the query.
......@@ -557,13 +569,20 @@ class Report {
$contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_REND]);
// Trigger subqueries of this level
$contentLevel .= $this->triggerReport($cur_level + 1, $this->indexArray[$counter], $counter + 1);
$contentSubquery = $this->triggerReport($cur_level + 1, $this->indexArray[$counter], $counter + 1);
$contentSubqueries .= $contentSubquery;
$contentLevel .= $contentSubquery;
// RENR
$contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_RENR]);
}
}
/////////////////////////////////////////////////
// Render TAIL, ALT_HEAD, ALT_SQL, STAIL //
/////////////////////////////////////////////////
if ($rowTotal > 0) {
// tail
$contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_TAIL]);
......@@ -590,9 +609,27 @@ class Report {
$contentLevel .= $this->variables->doVariables($this->frArray[$fullLevel . "." . TOKEN_STAIL]);
///////////////////////
// render TWIG //
///////////////////////
$twig_template = $this->frArray[$fullLevel . "." . TOKEN_TWIG];
if ($twig_template !== '') {
$contentLevel = $this->renderTwig($twig_template, $result, $keys);
}
////////////////////////////////////////////////////////////
// Show / Hide / Save Level Content (TOKEN_CONTENT) //
////////////////////////////////////////////////////////////
$token = $this->frArray[$fullLevel . "." . TOKEN_CONTENT];
switch ($token) {
case TOKEN_CONTENT_HIDE_LEVEL:
$this->variables->resultArray[$fullLevel . ".line."][TOKEN_CONTENT] = $contentLevel;
$contentLevel = $contentSubqueries;
break;
case TOKEN_CONTENT_HIDE:
$this->variables->resultArray[$fullLevel . ".line."][TOKEN_CONTENT] = $contentLevel;
$contentLevel = '';
......@@ -611,6 +648,11 @@ class Report {
$content .= $contentLevel;
///////////////////////////////
// Switch to next Level //
///////////////////////////////
++$counter;
if (isset($this->indexArray[$counter]) && is_array($this->indexArray[$counter])) {
$fullLevel = implode(".", $this->indexArray[$counter]);
......@@ -618,10 +660,73 @@ class Report {
$fullLevel = '';
}
}
return $content;
}
/**
* Render given Twig template with content from $result
*
* @param string $twig_template
* @param string $result - result from sql querry in mode ROW_KEYS
* @param string $keys - Column names given by sql query
*
* @return string The rendered Twig template
*
* @throws \CodeException
* @throws \Twig\Error\LoaderError
* @throws \Twig\Error\RuntimeError
* @throws \Twig\Error\SyntaxError
* @throws \UserFormException
* @throws \UserReportException
*/
private function renderTwig($twig_template, $result, $keys) {
if (count(array_unique($keys)) !== count($keys)) {
throw new \UserReportException("Twig Error: There are multiple columns with the same name in the SQL query.", ERROR_TWIG_COLUMN_NOT_UNIQUE);
}
// Turn Result into Associative array
$resultAssoc = array();
foreach ($result as $i => $row) {
foreach ($row as $j => $value) {
$resultAssoc[$i][$keys[$j]] = $value;
}
}
$tmpl_start = substr($twig_template, 0, 5);
if ($tmpl_start == "file:") {
$loader = new \Twig\Loader\FilesystemLoader([".", "typo3conf/ext/qfq/Resources/Public/twig_templates"]);
$twig_template = substr($twig_template, 5);
} else {
$loader = new \Twig\Loader\ArrayLoader(array(
"string_template" => trim($twig_template, '"\'')) # trim is needed for backward compatibility for MNF
);
$twig_template = "string_template";
}
$twig = new \Twig\Environment($loader, array());
// Add QFQ Link Filter
$filter = new \Twig\TwigFilter('qfqlink', function ($string) {
return $this->link->renderLink($string);
}, ['is_safe' => ['html']]);
$twig->addFilter($filter);
// render Twig
$contentTwig = $twig->render($twig_template, array(
'context' => $resultAssoc, // backward compatibility for MNF
'result' => $resultAssoc,
'store' => array(
'record' => $this->store->getStore(STORE_RECORD),
'sip' => $this->store->getStore(STORE_SIP),
'typo3' => $this->store->getStore(STORE_TYPO3),
'user' => $this->store->getStore(STORE_USER),
'system' => $this->store->getStore(STORE_SYSTEM),
)
));
return $contentTwig;
}
/**
* Determine value:
* 1) if one specified in line: take it
......
......@@ -49,4 +49,20 @@ BEGIN
DECLARE output TEXT;
SET output = REPLACE(input, '|', '\\|');
RETURN output;
END;
\ No newline at end of file
END;
###
#
# QNL2BR(input)
# replaces '|' in `input` with '\|'
#
DROP FUNCTION IF EXISTS QNL2BR;
CREATE FUNCTION QNL2BR ( input TEXT )
RETURNS TEXT
DETERMINISTIC
BEGIN
DECLARE output TEXT;
SET output = REPLACE( REPLACE(input, CHAR(13), ''), CHAR(10), '<br>');
RETURN output;
END
\ No newline at end of file
......@@ -37,6 +37,34 @@ Bug Fixes
^^^^^^^^^
Version 19.7.1
--------------
Date: 17.07.2019
Notes
^^^^^
* TWIG integration
Features
^^^^^^^^
* TWIG:
* Add Twig Extension to parse QFQ Links
* Pass assoc-array of query result as context
* allow to pass template as string
* Database.php: extend function sql error message
* #8179 / extraButtonLock and extraButtonPassword might be specified without a value or with 0 or with 1.
Bug Fixes
^^^^^^^^^
* Database.php: fix CodeException
* Fabric: Readonly now displays annotations again.
Version 19.7.0
--------------
......@@ -51,8 +79,8 @@ Notes
Features
^^^^^^^^
* #7284 tablesorter save sort order
* #8592 Fixed button display, added Alert instead of disabled button.
* #7284 / tablesorter save sort order
* #8592 / Fixed button display, added Alert instead of disabled button.
* #8204 / position required mark
* Excel.php: Delete superficial autoloader require
* Cleanup: hashPassword.php moved from Api to External.
......
<table class="table table-hover tablesorter tablesorter-filter">
{% for row in result %}
{% if loop.index0 == 0 %}
<thead><tr>
{% for key, value in context.0 %}
<th>{{ key|replace({'_': ' '})|capitalize }}</th>
{% endfor %}
</tr></thead><tbody>