Merge pull request #1878 from SchrodingersGat/js-template-stuff

Js template stuff
This commit is contained in:
Oliver 2021-07-29 13:42:47 +10:00 committed by GitHub
commit 935ef968de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 380 additions and 116 deletions

2
.gitattributes vendored
View File

@ -7,5 +7,5 @@
*.yml text
*.yaml text
*.conf text
*.sh text
*.sh text eol=lf
*.js text

28
.github/workflows/javascript.yaml vendored Normal file
View File

@ -0,0 +1,28 @@
# Check javascript template files
name: Javascript Templates
on:
push:
branches:
- master
pull_request:
branches-ignore:
- l10*
jobs:
javascript:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Check Files
run: |
cd ci
python check_js_templates.py

View File

@ -202,7 +202,7 @@ STATICFILES_DIRS = [
# Translated Template settings
STATICFILES_I18_PREFIX = 'i18n'
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js')
STATICFILES_I18_SRC = os.path.join(BASE_DIR, 'templates', 'js', 'translated')
STATICFILES_I18_TRG = STATICFILES_DIRS[0] + '_' + STATICFILES_I18_PREFIX
STATICFILES_DIRS.append(STATICFILES_I18_TRG)
STATICFILES_I18_TRG = os.path.join(STATICFILES_I18_TRG, STATICFILES_I18_PREFIX)

View File

@ -93,28 +93,33 @@ settings_urls = [
url(r'^.*$', SettingsView.as_view(template_name='InvenTree/settings/settings.html'), name='settings'),
]
# Some javascript files are served 'dynamically', allowing them to pass through the Django translation layer
# These javascript files are served "dynamically" - i.e. rendered on demand
dynamic_javascript_urls = [
url(r'^api.js', DynamicJsView.as_view(template_name='js/api.js'), name='api.js'),
url(r'^attachment.js', DynamicJsView.as_view(template_name='js/attachment.js'), name='attachment.js'),
url(r'^barcode.js', DynamicJsView.as_view(template_name='js/barcode.js'), name='barcode.js'),
url(r'^bom.js', DynamicJsView.as_view(template_name='js/bom.js'), name='bom.js'),
url(r'^build.js', DynamicJsView.as_view(template_name='js/build.js'), name='build.js'),
url(r'^calendar.js', DynamicJsView.as_view(template_name='js/calendar.js'), name='calendar.js'),
url(r'^company.js', DynamicJsView.as_view(template_name='js/company.js'), name='company.js'),
url(r'^filters.js', DynamicJsView.as_view(template_name='js/filters.js'), name='filters.js'),
url(r'^forms.js', DynamicJsView.as_view(template_name='js/forms.js'), name='forms.js'),
url(r'^inventree.js', DynamicJsView.as_view(template_name='js/inventree.js'), name='inventree.js'),
url(r'^label.js', DynamicJsView.as_view(template_name='js/label.js'), name='label.js'),
url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/model_renderers.js'), name='model_renderers.js'),
url(r'^modals.js', DynamicJsView.as_view(template_name='js/modals.js'), name='modals.js'),
url(r'^nav.js', DynamicJsView.as_view(template_name='js/nav.js'), name='nav.js'),
url(r'^order.js', DynamicJsView.as_view(template_name='js/order.js'), name='order.js'),
url(r'^part.js', DynamicJsView.as_view(template_name='js/part.js'), name='part.js'),
url(r'^report.js', DynamicJsView.as_view(template_name='js/report.js'), name='report.js'),
url(r'^stock.js', DynamicJsView.as_view(template_name='js/stock.js'), name='stock.js'),
url(r'^tables.js', DynamicJsView.as_view(template_name='js/tables.js'), name='tables.js'),
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/table_filters.js'), name='table_filters.js'),
url(r'^inventree.js', DynamicJsView.as_view(template_name='js/dynamic/inventree.js'), name='inventree.js'),
url(r'^calendar.js', DynamicJsView.as_view(template_name='js/dynamic/calendar.js'), name='calendar.js'),
url(r'^nav.js', DynamicJsView.as_view(template_name='js/dynamic/nav.js'), name='nav.js'),
url(r'^settings.js', DynamicJsView.as_view(template_name='js/dynamic/settings.js'), name='settings.js'),
]
# These javascript files are pased through the Django translation layer
translated_javascript_urls = [
url(r'^api.js', DynamicJsView.as_view(template_name='js/translated/api.js'), name='api.js'),
url(r'^attachment.js', DynamicJsView.as_view(template_name='js/translated/attachment.js'), name='attachment.js'),
url(r'^barcode.js', DynamicJsView.as_view(template_name='js/translated/barcode.js'), name='barcode.js'),
url(r'^bom.js', DynamicJsView.as_view(template_name='js/translated/bom.js'), name='bom.js'),
url(r'^build.js', DynamicJsView.as_view(template_name='js/translated/build.js'), name='build.js'),
url(r'^company.js', DynamicJsView.as_view(template_name='js/translated/company.js'), name='company.js'),
url(r'^filters.js', DynamicJsView.as_view(template_name='js/translated/filters.js'), name='filters.js'),
url(r'^forms.js', DynamicJsView.as_view(template_name='js/translated/forms.js'), name='forms.js'),
url(r'^label.js', DynamicJsView.as_view(template_name='js/translated/label.js'), name='label.js'),
url(r'^model_renderers.js', DynamicJsView.as_view(template_name='js/translated/model_renderers.js'), name='model_renderers.js'),
url(r'^modals.js', DynamicJsView.as_view(template_name='js/translated/modals.js'), name='modals.js'),
url(r'^order.js', DynamicJsView.as_view(template_name='js/translated/order.js'), name='order.js'),
url(r'^part.js', DynamicJsView.as_view(template_name='js/translated/part.js'), name='part.js'),
url(r'^report.js', DynamicJsView.as_view(template_name='js/translated/report.js'), name='report.js'),
url(r'^stock.js', DynamicJsView.as_view(template_name='js/translated/stock.js'), name='stock.js'),
url(r'^tables.js', DynamicJsView.as_view(template_name='js/translated/tables.js'), name='tables.js'),
url(r'^table_filters.js', DynamicJsView.as_view(template_name='js/translated/table_filters.js'), name='table_filters.js'),
]
urlpatterns = [
@ -123,7 +128,8 @@ urlpatterns = [
url(r'^supplier-part/', include(supplier_part_urls)),
# "Dynamic" javascript files which are rendered using InvenTree templating.
url(r'^dynamic/', include(dynamic_javascript_urls)),
url(r'^js/dynamic/', include(dynamic_javascript_urls)),
url(r'^js/i18n/', include(translated_javascript_urls)),
url(r'^common/', include(common_urls)),

View File

@ -82,7 +82,7 @@
},
{
success: function(response) {
var prefix = '{% settings_value "BUILDORDER_REFERENCE_PREFIX" %}';
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) {

View File

@ -38,6 +38,67 @@ class BaseInvenTreeSetting(models.Model):
class Meta:
abstract = True
@classmethod
def allValues(cls, user=None):
"""
Return a dict of "all" defined global settings.
This performs a single database lookup,
and then any settings which are not *in* the database
are assigned their default values
"""
keys = set()
settings = []
results = cls.objects.all()
if user is not None:
results = results.filter(user=user)
# Query the database
for setting in results:
settings.append({
"key": setting.key.upper(),
"value": setting.value
})
keys.add(setting.key.upper())
# Specify any "default" values which are not in the database
for key in cls.GLOBAL_SETTINGS.keys():
if key.upper() not in keys:
settings.append({
"key": key.upper(),
"value": cls.get_setting_default(key)
})
# Enforce javascript formatting
for idx, setting in enumerate(settings):
key = setting['key']
value = setting['value']
validator = cls.get_setting_validator(key)
# Convert to javascript compatible booleans
if cls.validator_is_bool(validator):
value = value.lower()
# Numerical values remain the same
elif cls.validator_is_int(validator):
pass
# Wrap strings with quotes
else:
value = f"'{value}'"
setting["value"] = value
return settings
@classmethod
def get_setting_name(cls, key):
"""
@ -368,13 +429,7 @@ class BaseInvenTreeSetting(models.Model):
validator = self.__class__.get_setting_validator(self.key)
if validator == bool:
return True
if type(validator) in [list, tuple]:
for v in validator:
if v == bool:
return True
return self.__class__.validator_is_bool(validator)
def as_bool(self):
"""
@ -385,6 +440,19 @@ class BaseInvenTreeSetting(models.Model):
return InvenTree.helpers.str2bool(self.value)
@classmethod
def validator_is_bool(cls, validator):
if validator == bool:
return True
if type(validator) in [list, tuple]:
for v in validator:
if v == bool:
return True
return False
def is_int(self):
"""
Check if the setting is required to be an integer value:
@ -392,6 +460,11 @@ class BaseInvenTreeSetting(models.Model):
validator = self.__class__.get_setting_validator(self.key)
return self.__class__.validator_is_int(validator)
@classmethod
def validator_is_int(cls, validator):
if validator == int:
return True

View File

@ -198,17 +198,16 @@
);
});
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
if (global_settings.INVENTREE_DOWNLOAD_FROM_URL) {
{% if allow_download %}
$('#company-image-url').click(function() {
launchModalForm(
'{% url "company-image-download" company.id %}',
{
reload: true,
}
)
});
{% endif %}
$('#company-image-url').click(function() {
launchModalForm(
'{% url "company-image-download" company.id %}',
{
reload: true,
}
)
});
}
{% endblock %}

View File

@ -164,7 +164,7 @@ $("#edit-order").click(function() {
constructForm('{% url "api-po-detail" order.pk %}', {
fields: {
reference: {
prefix: "{% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}",
prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX,
},
{% if order.lines.count == 0 and order.status == PurchaseOrderStatus.PENDING %}
supplier: {

View File

@ -66,7 +66,7 @@
},
{
success: function(response) {
var prefix = '{% settings_value "PURCHASEORDER_REFERENCE_PREFIX" %}';
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) {

View File

@ -157,7 +157,7 @@ $("#edit-order").click(function() {
constructForm('{% url "api-so-detail" order.pk %}', {
fields: {
reference: {
prefix: "{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}",
prefix: global_settings.SALESORDER_REFERENCE_PREFIX,
},
{% if order.lines.count == 0 and order.status == SalesOrderStatus.PENDING %}
customer: {

View File

@ -67,7 +67,7 @@
{
success: function(response) {
var prefix = '{% settings_value "SALESORDER_REFERENCE_PREFIX" %}';
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
for (var idx = 0; idx < response.length; idx++) {
var order = response[idx];

View File

@ -394,17 +394,16 @@
{% if roles.part.change %}
{% settings_value "INVENTREE_DOWNLOAD_FROM_URL" as allow_download %}
{% if allow_download %}
$("#part-image-url").click(function() {
launchModalForm(
'{% url "part-image-download" part.id %}',
{
reload: true,
}
);
});
{% endif %}
if (global_settings.INVENTREE_DOWNLOAD_FROM_URL) {
$("#part-image-url").click(function() {
launchModalForm(
'{% url "part-image-download" part.id %}',
{
reload: true,
}
);
});
}
$("#part-image-select").click(function() {
launchModalForm("{% url 'part-image-select' part.id %}",

View File

@ -207,6 +207,24 @@ def settings_value(key, *args, **kwargs):
return InvenTreeSetting.get_setting(key)
@register.simple_tag()
def user_settings(user, *args, **kwargs):
"""
Return all USER settings as a key:value dict
"""
return InvenTreeUserSetting.allValues(user=user)
@register.simple_tag()
def global_settings(*args, **kwargs):
"""
Return all GLOBAL InvenTree settings as a key:value dict
"""
return InvenTreeSetting.allValues()
@register.simple_tag()
def get_color_theme_css(username):
try:

View File

@ -145,19 +145,22 @@
<script type='text/javascript' src="{% static 'script/inventree/notification.js' %}"></script>
<script type='text/javascript' src="{% static 'script/inventree/sidenav.js' %}"></script>
<!-- translated -->
<script type='text/javascript' src="{% i18n_static 'inventree.js' %}"></script>
<!-- dynamic javascript templates -->
<script type='text/javascript' src="{% url 'inventree.js' %}"></script>
<script type='text/javascript' src="{% url 'calendar.js' %}"></script>
<script type='text/javascript' src="{% url 'nav.js' %}"></script>
<script type='text/javascript' src="{% url 'settings.js' %}"></script>
<!-- translated javascript templates-->
<script type='text/javascript' src="{% i18n_static 'api.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'attachment.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'barcode.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'bom.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'build.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'calendar.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'company.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'filters.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'forms.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'label.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'nav.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'modals.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'model_renderers.js' %}"></script>
<script type='text/javascript' src="{% i18n_static 'order.js' %}"></script>

View File

@ -91,11 +91,7 @@ function inventreeDocReady() {
url: '/api/part/',
data: {
search: request.term,
{% if request.user %}
limit: {% settings_value 'SEARCH_PREVIEW_RESULTS' user=request.user %},
{% else %}
limit: 25,
{% endif %}
limit: user_settings.SEARCH_PREVIEW_RESULTS,
offset: 0
},
success: function (data) {

View File

@ -0,0 +1,17 @@
{% load inventree_extras %}
// InvenTree settings
{% user_settings request.user as USER_SETTINGS %}
{% global_settings as GLOBAL_SETTINGS %}
var user_settings = {
{% for setting in USER_SETTINGS %}
{{ setting.key }}: {{ setting.value|safe }},
{% endfor %}
};
var global_settings = {
{% for setting in GLOBAL_SETTINGS %}
{{ setting.key }}: {{ setting.value|safe }},
{% endfor %}
};

View File

@ -5,7 +5,7 @@
function buildFormFields() {
return {
reference: {
prefix: "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}",
prefix: global_settings.BUILDORDER_REFERENCE_PREFIX,
},
title: {},
part: {},
@ -232,7 +232,7 @@ function loadBuildOrderAllocationTable(table, options={}) {
switchable: false,
title: '{% trans "Build Order" %}',
formatter: function(value, row) {
var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}";
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
var ref = `${prefix}${row.build_detail.reference}`;
@ -848,7 +848,7 @@ function loadBuildTable(table, options) {
switchable: true,
formatter: function(value, row, index, field) {
var prefix = "{% settings_value 'BUILDORDER_REFERENCE_PREFIX' %}";
var prefix = global_settings.BUILDORDER_REFERENCE_PREFIX;
if (prefix) {
value = `${prefix}${value}`;

View File

@ -9,7 +9,7 @@ function createSalesOrder(options={}) {
method: 'POST',
fields: {
reference: {
prefix: '{% settings_value "SALESORDER_REFERENCE_PREFIX" %}',
prefix: global_settings.SALESORDER_REFERENCE_PREFIX,
},
customer: {
value: options.customer,
@ -40,7 +40,7 @@ function createPurchaseOrder(options={}) {
method: 'POST',
fields: {
reference: {
prefix: "{% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}",
prefix: global_settings.PURCHASEORDER_REFERENCE_PREFIX,
},
supplier: {
value: options.supplier,
@ -214,7 +214,7 @@ function loadPurchaseOrderTable(table, options) {
switchable: false,
formatter: function(value, row, index, field) {
var prefix = "{% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}";
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
if (prefix) {
value = `${prefix}${value}`;
@ -309,7 +309,7 @@ function loadSalesOrderTable(table, options) {
title: '{% trans "Sales Order" %}',
formatter: function(value, row, index, field) {
var prefix = "{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}";
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
if (prefix) {
value = `${prefix}${value}`;
@ -423,7 +423,7 @@ function loadSalesOrderAllocationTable(table, options={}) {
switchable: false,
formatter: function(value, row) {
var prefix = "{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}";
var prefix = global_settings.SALESORDER_REFERENCE_PREFIX;
var ref = `${prefix}${row.order_detail.reference}`;

View File

@ -6,8 +6,6 @@
* Requires api.js to be loaded first
*/
{% settings_value 'BARCODE_ENABLE' as barcodes %}
function stockStatusCodes() {
return [
{% for code in StockStatus.list %}
@ -704,8 +702,7 @@ function loadStockTable(table, options) {
name: 'stock',
original: original,
showColumns: true,
{% settings_value 'STOCK_GROUP_BY_PART' as group_by_part %}
{% if group_by_part %}
{% if False %}
groupByField: options.groupByField || 'part',
groupBy: grouping,
groupByFormatter: function(field, id, data) {
@ -1011,14 +1008,13 @@ function loadStockTable(table, options) {
title: '{% trans "Stocktake" %}',
sortable: true,
},
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
{% if expiry %}
{
field: 'expiry_date',
title: '{% trans "Expiry Date" %}',
sortable: true,
visible: global_settings.STOCK_ENABLE_EXPIRY,
switchable: global_settings.STOCK_ENABLE_EXPIRY,
},
{% endif %}
{
field: 'updated',
title: '{% trans "Last Updated" %}',
@ -1037,7 +1033,7 @@ function loadStockTable(table, options) {
if (row.purchase_order_reference) {
var prefix = '{% settings_value "PURCHASEORDER_REFERENCE_PREFIX" %}';
var prefix = global_settings.PURCHASEORDER_REFERENCE_PREFIX;
text = prefix + row.purchase_order_reference;
}
@ -1090,15 +1086,18 @@ function loadStockTable(table, options) {
}
*/
var buttons = [
'#stock-print-options',
'#stock-options',
];
if (global_settings.BARCODE_ENABLE) {
buttons.push('#stock-barcode-options');
}
linkButtonsToSelection(
table,
[
'#stock-print-options',
{% if barcodes %}
'#stock-barcode-options',
{% endif %}
'#stock-options',
]
buttons,
);
@ -1138,19 +1137,19 @@ function loadStockTable(table, options) {
printTestReports(items);
})
{% if barcodes %}
$('#multi-item-barcode-scan-into-location').click(function() {
var selections = $('#stock-table').bootstrapTable('getSelections');
if (global_settings.BARCODE_ENABLE) {
$('#multi-item-barcode-scan-into-location').click(function() {
var selections = $('#stock-table').bootstrapTable('getSelections');
var items = [];
var items = [];
selections.forEach(function(item) {
items.push(item.pk);
})
selections.forEach(function(item) {
items.push(item.pk);
})
scanItemsIntoLocation(items);
});
{% endif %}
scanItemsIntoLocation(items);
});
}
$('#multi-item-stocktake').click(function() {
stockAdjustment('count');

View File

@ -121,7 +121,8 @@ function getAvailableTableFilters(tableKey) {
// Filters for the "Stock" table
if (tableKey == 'stock') {
return {
var filters = {
active: {
type: 'bool',
title: '{% trans "Active parts" %}',
@ -147,19 +148,6 @@ function getAvailableTableFilters(tableKey) {
title: '{% trans "Depleted" %}',
description: '{% trans "Show stock items which are depleted" %}',
},
{% settings_value "STOCK_ENABLE_EXPIRY" as expiry %}
{% if expiry %}
expired: {
type: 'bool',
title: '{% trans "Expired" %}',
description: '{% trans "Show stock items which have expired" %}',
},
stale: {
type: 'bool',
title: '{% trans "Stale" %}',
description: '{% trans "Show stock which is close to expiring" %}',
},
{% endif %}
in_stock: {
type: 'bool',
title: '{% trans "In Stock" %}',
@ -216,6 +204,23 @@ function getAvailableTableFilters(tableKey) {
description: '{% trans "Show stock items which have a purchase price set" %}',
},
};
// Optional filters if stock expiry functionality is enabled
if (global_settings.STOCK_ENABLE_EXPIRY) {
filters.expired = {
type: 'bool',
title: '{% trans "Expired" %}',
description: '{% trans "Show stock items which have expired" %}',
};
filters.stale = {
type: 'bool',
title: '{% trans "Stale" %}',
description: '{% trans "Show stock which is close to expiring" %}',
};
}
return filters;
}
// Filters for the 'stock test' table

121
ci/check_js_templates.py Normal file
View File

@ -0,0 +1,121 @@
"""
Test that the "translated" javascript files to not contain template tags
which need to be determined at "run time".
This is because the "translated" javascript files are compiled into the "static" directory.
They should only contain template tags that render static information.
"""
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import sys
import re
import os
import pathlib
here = os.path.abspath(os.path.dirname(__file__))
template_dir = os.path.abspath(os.path.join(here, '..', 'InvenTree', 'templates'))
# We only care about the 'translated' files
js_i18n_dir = os.path.join(template_dir, 'js', 'translated')
js_dynamic_dir = os.path.join(template_dir, 'js', 'dynamic')
errors = 0
print("=================================")
print("Checking static javascript files:")
print("=================================")
def check_invalid_tag(data):
pattern = r"{%(\w+)"
err_count = 0
for idx, line in enumerate(data):
results = re.findall(pattern, line)
for result in results:
err_count += 1
print(f" - Error on line {idx+1}: %{{{result[0]}")
return err_count
def check_prohibited_tags(data):
allowed_tags = [
'if',
'elif',
'else',
'endif',
'for',
'endfor',
'trans',
'load',
'include',
'url',
]
pattern = r"{% (\w+)\s"
err_count = 0
has_trans = False
for idx, line in enumerate(data):
for tag in re.findall(pattern, line):
if tag not in allowed_tags:
print(f" > Line {idx+1} contains prohibited template tag '{tag}'")
err_count += 1
if tag == 'trans':
has_trans = True
if not has_trans:
print(f" > file is missing 'trans' tags")
err_count += 1
return err_count
for filename in pathlib.Path(js_i18n_dir).rglob('*.js'):
print(f"Checking file 'translated/{os.path.basename(filename)}':")
with open(filename, 'r') as js_file:
data = js_file.readlines()
errors += check_invalid_tag(data)
errors += check_prohibited_tags(data)
for filename in pathlib.Path(js_dynamic_dir).rglob('*.js'):
print(f"Checking file 'dynamic/{os.path.basename(filename)}':")
# Check that the 'dynamic' files do not contains any translated strings
with open(filename, 'r') as js_file:
data = js_file.readlines()
pattern = r'{% trans '
err_count = 0
for idx, line in enumerate(data):
results = re.findall(pattern, line)
if len(results) > 0:
errors += 1
print(f" > prohibited {{% trans %}} tag found at line {idx + 1}")
if errors > 0:
print(f"Found {errors} incorrect template tags")
sys.exit(errors)