Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • andersharrisson/csentry
  • ics-infrastructure/csentry
2 results
Show changes
Commits on Source (29)
---
include:
- 'https://gitlab.esss.lu.se/ics-infrastructure/gitlab-ci-yml/raw/master/PreCommit.gitlab-ci.yml'
.runner_tags: &runner_tags
tags:
- docker
- remote: "https://gitlab.esss.lu.se/ics-infrastructure/gitlab-ci-yml/raw/master/PreCommit.gitlab-ci.yml"
- remote: "https://gitlab.esss.lu.se/ics-infrastructure/gitlab-ci-yml/raw/master/SonarScanner.gitlab-ci.yml"
variables:
CONTAINER_TEST_IMAGE: "$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME"
......@@ -22,11 +19,14 @@ stages:
- release
- deploy
default:
tags:
- docker
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
build:
<<: *runner_tags
stage: build
image: docker:latest
script:
......@@ -35,7 +35,6 @@ build:
- docker push "$CONTAINER_TEST_IMAGE"
test:
<<: *runner_tags
stage: test
image: "$CONTAINER_TEST_IMAGE"
services:
......@@ -56,16 +55,7 @@ test:
junit: junit.xml
expire_in: 1 hour
analyse:
<<: *runner_tags
stage: analyse
image: registry.esss.lu.se/ics-docker/sonar-scanner:3
before_script: []
script:
- sonar-scanner -Dsonar.login=$SONARQUBE_TOKEN -Dsonar.projectVersion=$CI_COMMIT_REF_NAME
release-image:
<<: *runner_tags
stage: release
image: docker:latest
dependencies: []
......@@ -76,8 +66,26 @@ release-image:
only:
- tags
deploy-dev:
stage: deploy
image: registry.esss.lu.se/ics-docker/tower-cli
before_script: []
dependencies: []
script:
- >
tower-cli job launch
-h torn.tn.esss.lu.se
-t ${TOWER_OAUTH_TOKEN}
-J deploy-csentry-dev
-e "csentry_tag=$CI_COMMIT_REF_NAME" --monitor
environment:
name: dev
url: https://csentry-lab-01.cslab.esss.lu.se
except:
- master
- tags
deploy-staging:
<<: *runner_tags
stage: deploy
image: registry.esss.lu.se/ics-docker/tower-cli
before_script: []
......@@ -97,7 +105,6 @@ deploy-staging:
- tags
pages:
<<: *runner_tags
stage: deploy
image: "$CONTAINER_RELEASE_IMAGE"
dependencies: []
......@@ -115,7 +122,6 @@ pages:
- tags
deploy-production:
<<: *runner_tags
stage: deploy
image: registry.esss.lu.se/ics-docker/tower-cli
before_script: []
......
repos:
- repo: https://github.com/psf/black
rev: 20.8b1
rev: 22.6.0
hooks:
- id: black
- repo: https://gitlab.com/pycqa/flake8
rev: 3.8.3
- repo: https://github.com/pycqa/flake8
rev: 4.0.1
hooks:
- id: flake8
Changelog
=========
Version 2021.04.06
------------------
- Validate group names (INFRA-3135)
- Add warning text for creating vm (INFRA-3292)
- Paginate history in ansible groups and hosts (INFRA-3136)
Version 2020.11.25
------------------
- Add documentation about database versioning
- Set display_name to username when empty (INFRA-2909)
- Improve logging (INFRA-2899)
Version 2020.11.13
------------------
- Increased hostname character limit from 20 -> 24 (INFRA-2808)
- Use SSL by default for LDAP connection (INFRA-2805)
- Use SonarScanner.gitlab-ci.yml template (INFRA-2832)
Version 2020.10.30
------------------
- Fix inventory export to excel (INFRA-2780)
- Add developer documentation
Version 2020.10.27
------------------
- Update Python and requirements (INFRA-2649)
- Update RQ and rq-dashboard (INFRA-2649 / INFRA-2754)
- Fix code smells (INFRA-2729)
- Add auditor group (INFRA-2728)
- Update Sphinx and switch to sphinx_rtd_theme (INFRA-2742)
Version 2020.10.06
------------------
......
FROM python:3.8-slim as base
# Add ESS specific debian repository mirror
RUN echo "deb https://artifactory.esss.lu.se/artifactory/debian-mirror stable main" | tee /etc/apt/sources.list.d/ess-debian-mirror.list
# Install Python dependencies in an intermediate image
# as some requires a compiler (psycopg2)
FROM base as builder
......@@ -65,6 +68,8 @@ RUN apt-get update \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN rm /etc/apt/sources.list.d/ess-debian-mirror.list
COPY --chown=csi:csi . /app/
WORKDIR /app
RUN echo "__version__ = \"$(git describe)\"" > app/_version.py
......
.PHONY: help build tag push refresh release db init_db upgrade_db test db_image test_image
.PHONY: help build tag push refresh release db init_db upgrade_db test db_image test_image docs
OWNER := registry.esss.lu.se/ics-infrastructure
GIT_TAG := $(shell git describe --always)
......@@ -60,3 +60,6 @@ test_image: ## run the tests (on the latest image)
run_uwsgi: ## run the application with uwsgi (to test prod env)
docker-compose run -p 8000:8000 --rm web uwsgi --master --http 0.0.0.0:8000 --http-keepalive --manage-script-name --mount /csentry=wsgi.py --callable app --uid conda --processes 2 -b 16384
docs: ## run the tests (on current directory)
docker run --rm -v $(shell pwd):/app registry.esss.lu.se/ics-infrastructure/csentry:master sphinx-build -M html docs docs/_build
......@@ -313,7 +313,7 @@ def patch_interface(interface_id):
@bp.route("/interfaces/<int:interface_id>", methods=["DELETE"])
@login_groups_accepted("admin")
@login_groups_accepted("admin", "network")
def delete_interface(interface_id):
"""Delete an interface
......
......@@ -12,6 +12,7 @@ This module implements useful functions for the API.
import urllib.parse
import sqlalchemy as sa
from flask import current_app, jsonify, request
from flask_login import current_user
from flask_sqlalchemy import Pagination
from ..extensions import db
from .. import utils
......@@ -124,7 +125,6 @@ def get_json_body():
data = request.get_json()
if data is None:
raise utils.CSEntryError("Body should be a JSON object")
current_app.logger.debug(f"Received: {data}")
if not data:
raise utils.CSEntryError("At least one field is required", status_code=422)
return data
......@@ -154,6 +154,9 @@ def create_generic_model(model, mandatory_fields=("name",), **kwargs):
raise utils.CSEntryError(str(e), status_code=422)
db.session.add(instance)
commit()
current_app.logger.info(
f"New {model.__tablename__} created by {current_user}: {instance.to_dict()}"
)
return jsonify(instance.to_dict()), 201
......@@ -166,6 +169,9 @@ def delete_generic_model(model, primary_key):
instance = model.query.get_or_404(primary_key)
db.session.delete(instance)
commit()
current_app.logger.info(
f"{model.__tablename__} {instance} deleted by {current_user}"
)
return jsonify(), 204
......@@ -195,4 +201,7 @@ def update_generic_model(model, primary_key, allowed_fields):
except Exception as e:
raise utils.CSEntryError(str(e), status_code=422)
commit()
current_app.logger.info(
f"{model.__tablename__} {primary_key} updated by {current_user}: {instance.to_dict()}"
)
return jsonify(instance.to_dict()), 200
......@@ -54,7 +54,9 @@ def sync_user(connection, user):
disable_user(user)
else:
attributes = ldap_user["attributes"]
user.display_name = utils.attribute_to_string(attributes["cn"])
user.display_name = (
utils.attribute_to_string(attributes["cn"]) or user.username
)
user.email = utils.attribute_to_string(attributes["mail"])
groups = ldap_manager.get_user_groups(
dn=ldap3.utils.conv.escape_filter_chars(ldap_user["dn"]),
......
......@@ -21,6 +21,7 @@ def login_groups_accepted(*groups):
This can be used for users logged in using a cookie (web UI) or JWT (API).
Example::
@bp.route('/models', methods=['POST'])
@login_groups_accepted('admin', 'inventory')
def create_model():
......
......@@ -72,19 +72,7 @@ def create_app(config=None):
integrations=[FlaskIntegration()],
)
if not app.debug:
# Log to stderr
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter(
"%(asctime)s %(levelname)s: %(message)s " "[in %(pathname)s:%(lineno)d]"
)
)
# Set app logger level to DEBUG
# otherwise only WARNING and above are propagated
app.logger.setLevel(logging.DEBUG)
handler.setLevel(logging.DEBUG)
app.logger.addHandler(handler)
app.logger.setLevel(logging.DEBUG)
app.logger.info("CSEntry created!")
# Remove variables that contain a password
settings_to_display = {
......@@ -107,6 +95,9 @@ def create_app(config=None):
)
app.logger.info(f"Settings:\n{settings_string}")
# Force RQ_DASHBOARD_REDIS_URL to RQ_REDIS_URL
app.config["RQ_DASHBOARD_REDIS_URL"] = app.config["RQ_REDIS_URL"]
db.init_app(app)
migrate.init_app(app)
login_manager.init_app(app)
......
......@@ -46,7 +46,7 @@ def list_items():
@login_required
def _generate_excel_file():
task = current_user.launch_task(
"generate_items_excel_file", func="generate_items_excel_file", timeout=180
"generate_items_excel_file", func="generate_items_excel_file", job_timeout=180
)
db.session.commit()
return utils.redirect_to_job_status(task.id)
......
......@@ -99,6 +99,7 @@ def pop_rq_connection(exception=None):
@bp.route("/")
@login_required
def index():
"""Return the application index"""
return render_template("index.html")
......
......@@ -32,6 +32,7 @@ from .plugins import FlaskUserPlugin
from .validators import (
ICS_ID_RE,
HOST_NAME_RE,
GROUP_NAME_RE,
INTERFACE_NAME_RE,
VLAN_NAME_RE,
MAC_ADDRESS_RE,
......@@ -101,7 +102,7 @@ def save_user(dn, username, data, memberships):
if user is None:
user = User(
username=username,
display_name=utils.attribute_to_string(data["cn"]),
display_name=utils.attribute_to_string(data["cn"]) or username,
email=utils.attribute_to_string(data["mail"]),
)
# Always update the user groups to keep them up-to-date
......@@ -1169,6 +1170,22 @@ class AnsibleGroup(CreatedMixin, SearchableMixin, db.Model):
check_parents(self)
return child
@validates("name")
def validate_name(self, key, string):
"""Ensure the name matches the required format"""
if string is None:
return None
# Force the string to lowercase
lower_string = string.lower()
if GROUP_NAME_RE.fullmatch(lower_string) is None:
raise ValidationError(f"Group name shall match {GROUP_NAME_RE.pattern}")
existing_group_name = AnsibleGroup.query.filter(
AnsibleGroup.name == lower_string, AnsibleGroup.id != self.id
).first()
if existing_group_name:
raise ValidationError("Group name matches an existing group")
return lower_string
@property
def is_dynamic(self):
return self.type != AnsibleGroupType.STATIC
......@@ -1464,7 +1481,7 @@ class Host(CreatedMixin, SearchableMixin, db.Model):
# Force the string to lowercase
lower_string = string.lower()
if HOST_NAME_RE.fullmatch(lower_string) is None:
raise ValidationError(r"Host name shall match [a-z0-9\-]{2,20}")
raise ValidationError(f"Host name shall match {HOST_NAME_RE.pattern}")
existing_cname = Cname.query.filter_by(name=lower_string).first()
if existing_cname:
raise ValidationError("Host name matches an existing cname")
......@@ -1594,7 +1611,9 @@ class Interface(CreatedMixin, db.Model):
# Force the string to lowercase
lower_string = string.lower()
if INTERFACE_NAME_RE.fullmatch(lower_string) is None:
raise ValidationError(r"Interface name shall match [a-z0-9\-]{2,25}")
raise ValidationError(
f"Interface name shall match {INTERFACE_NAME_RE.pattern}"
)
if self.host and not lower_string.startswith(self.host.name):
raise ValidationError(
f"Interface name shall start with the host name '{self.host}'"
......@@ -1729,7 +1748,7 @@ class Cname(CreatedMixin, db.Model):
# Force the string to lowercase
lower_string = string.lower()
if HOST_NAME_RE.fullmatch(lower_string) is None:
raise ValidationError(r"cname shall match [a-z0-9\-]{2,20}")
raise ValidationError(f"cname shall match {HOST_NAME_RE.pattern}")
existing_interface = Interface.query.filter_by(name=lower_string).first()
if existing_interface:
raise ValidationError("cname matches an existing interface")
......
......@@ -154,7 +154,7 @@ class EditNetworkForm(CSEntryForm):
class HostForm(CSEntryForm):
name = StringField(
"Hostname",
description="hostname must be 2-20 characters long and contain only letters, numbers and dash",
description="hostname must be 2-24 characters long and contain only letters, numbers and dash",
validators=[
validators.InputRequired(),
validators.Regexp(HOST_NAME_RE),
......@@ -165,7 +165,11 @@ class HostForm(CSEntryForm):
)
description = TextAreaField("Description")
device_type_id = SelectField("Device Type")
is_ioc = BooleanField("IOC", default=False)
is_ioc = BooleanField(
"IOC",
default=False,
description="This host will be used to run IOCs",
)
ansible_vars = YAMLField(
"Ansible vars",
description="Enter variables in YAML format. See https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html",
......@@ -199,7 +203,7 @@ class InterfaceForm(CSEntryForm):
)
interface_name = StringField(
"Interface name",
description="name must be 2-25 characters long and contain only letters, numbers and dash",
description="name must be 2-29 characters long and contain only letters, numbers and dash",
validators=[
validators.InputRequired(),
validators.Regexp(INTERFACE_NAME_RE),
......@@ -221,7 +225,7 @@ class InterfaceForm(CSEntryForm):
)
cnames_string = StringField(
"Cnames",
description="space separated list of cnames (must be 2-20 characters long and contain only letters, numbers and dash)",
description="space separated list of cnames (must be 2-24 characters long and contain only letters, numbers and dash)",
validators=[
validators.Optional(),
RegexpList(HOST_NAME_RE),
......
......@@ -110,7 +110,7 @@ def create_host():
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
return render_template("network/create_host.html", form=form)
current_app.logger.debug(f"Trying to create: {host!r}")
current_app.logger.debug(f"Trying to create: {host}")
db.session.add(host)
try:
db.session.commit()
......@@ -119,6 +119,9 @@ def create_host():
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Host {host} created by {current_user}: {host.to_dict()}"
)
flash(f"Host {host} created!", "success")
# Save network_id and device_type_id to the session to retrieve them after the redirect
session["network_id"] = network_id
......@@ -138,6 +141,7 @@ def delete_host():
# defined on the model
db.session.delete(host)
db.session.commit()
current_app.logger.info(f"Host {host} deleted by {current_user}")
flash(f"Host {host.name} has been deleted", "success")
if host.device_type.name == "VirtualMachine":
flash(
......@@ -202,7 +206,7 @@ def view_host(name):
utils.trigger_core_services_update()
db.session.commit()
current_app.logger.info(
f"Set network boot profile to {boot_profile} for {name} requested: task {task.id}"
f"Set network boot profile to {boot_profile} for {name} requested by {current_user}: task {task.id}"
)
flash(
f"Set network boot profile to {boot_profile} for {name} requested! "
......@@ -227,7 +231,9 @@ def view_host(name):
skip_post_install_job=form.skip_post_install_job.data,
)
db.session.commit()
current_app.logger.info(f"Creation of {name} requested: task {task.id}")
current_app.logger.info(
f"Creation of {name} requested by {current_user}: task {task.id}"
)
flash(
f"Creation of {name} requested! Refresh the page to update the status.",
"success",
......@@ -267,7 +273,7 @@ def edit_host(name):
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
return render_template("network/edit_host.html", form=form)
current_app.logger.debug(f"Trying to update: {host!r}")
current_app.logger.debug(f"Trying to update: {host}")
try:
db.session.commit()
except sa.exc.IntegrityError as e:
......@@ -275,6 +281,9 @@ def edit_host(name):
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Host {name} updated by {current_user}: {host.to_dict()}"
)
flash(f"Host {host} updated!", "success")
return redirect(url_for("network.view_host", name=host.name))
return render_template("network/edit_host.html", form=form)
......@@ -333,6 +342,9 @@ def create_interface(hostname):
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Interface {interface} created by {current_user}: {interface.to_dict()}"
)
flash(f"Interface {interface} created!", "success")
return redirect(url_for("network.create_interface", hostname=hostname))
return render_template(
......@@ -415,6 +427,9 @@ def edit_interface(name):
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Interface {name} updated by {current_user}: {interface.to_dict()}"
)
flash(f"Interface {interface} updated!", "success")
return redirect(url_for("network.view_host", name=interface.host.name))
return render_template(
......@@ -437,6 +452,7 @@ def delete_interface():
# defined on the model
db.session.delete(interface)
db.session.commit()
current_app.logger.info(f"Interface {interface} deleted by {current_user}")
flash(f"Interface {interface.name} has been deleted", "success")
return redirect(url_for("network.view_host", name=hostname))
......@@ -460,6 +476,7 @@ def delete_ansible_group():
group = models.AnsibleGroup.query.get_or_404(request.form["group_id"])
db.session.delete(group)
db.session.commit()
current_app.logger.info(f"Group {group} deleted by {current_user}")
flash(f"Group {group.name} has been deleted", "success")
return redirect(url_for("network.list_ansible_groups"))
......@@ -513,6 +530,9 @@ def edit_ansible_group(name):
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Group {name} updated by {current_user}: {group.to_dict()}"
)
flash(f"Group {group} updated!", "success")
return redirect(url_for("network.view_ansible_group", name=group.name))
return render_template("network/edit_group.html", form=form)
......@@ -547,6 +567,9 @@ def create_ansible_group():
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Group {group} created by {current_user}: {group.to_dict()}"
)
flash(f"Group {group} created!", "success")
return redirect(url_for("network.view_ansible_group", name=group.name))
return render_template("network/create_group.html", form=form)
......@@ -574,6 +597,9 @@ def create_domain():
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Domain {domain} created by {current_user}: {domain.to_dict()}"
)
flash(f"Domain {domain} created!", "success")
return redirect(url_for("network.create_domain"))
return render_template("network/create_domain.html", form=form)
......@@ -621,6 +647,9 @@ def create_scope():
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Network scope {scope} created by {current_user}: {scope.to_dict()}"
)
flash(f"Network Scope {scope} created!", "success")
return redirect(url_for("network.create_scope"))
return render_template("network/create_scope.html", form=form)
......@@ -655,6 +684,9 @@ def edit_scope(name):
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Network scope {name} updated by {current_user}: {scope.to_dict()}"
)
flash(f"Network Scope {scope} updated!", "success")
return redirect(url_for("network.view_scope", name=scope.name))
return render_template("network/edit_scope.html", form=form)
......@@ -734,6 +766,9 @@ def create_network():
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Network {network} created by {current_user}: {network.to_dict()}"
)
flash(f"Network {network} created!", "success")
# Save scope_id to the session to retrieve it after the redirect
session["scope_id"] = scope_id
......@@ -776,6 +811,9 @@ def edit_network(vlan_name):
current_app.logger.warning(f"{e}")
flash(f"{e}", "error")
else:
current_app.logger.info(
f"Network {vlan_name} updated by {current_user}: {network.to_dict()}"
)
flash(f"Network {network} updated!", "success")
return redirect(url_for("network.view_network", vlan_name=network.vlan_name))
return render_template("network/edit_network.html", form=form)
......
......@@ -33,7 +33,6 @@ SESSION_REDIS_URL = "redis://redis:6379/0"
CACHE_TYPE = "redis"
CACHE_REDIS_URL = "redis://redis:6379/1"
RQ_REDIS_URL = "redis://redis:6379/2"
RQ_DASHBOARD_REDIS_URL = RQ_REDIS_URL
ELASTICSEARCH_URL = "http://elasticsearch:9200"
ELASTICSEARCH_INDEX_SUFFIX = "-dev"
......@@ -43,8 +42,8 @@ ELASTICSEARCH_INDEX_SUFFIX = "-dev"
ELASTICSEARCH_REFRESH = "false"
LDAP_HOST = os.environ.get("LDAP_HOST", "esss.lu.se")
LDAP_PORT = int(os.environ.get("LDAP_PORT", 389))
LDAP_USE_SSL = os.environ.get("LDAP_USE_SSL", "false").lower() == "true"
LDAP_PORT = int(os.environ.get("LDAP_PORT", 636))
LDAP_USE_SSL = os.environ.get("LDAP_USE_SSL", "true").lower() == "true"
LDAP_BASE_DN = os.environ.get("LDAP_BASE_DN", "DC=esss,DC=lu,DC=se")
LDAP_USER_DN = os.environ.get("LDAP_USER_DN", "")
LDAP_GROUP_DN = os.environ.get("LDAP_GROUP_DN", "")
......
$(document).ready(function () {
$("#host_version_table").DataTable({
"lengthMenu": [[1, 5, 10, 25, -1], [1, 5, 10, 25, "All"]],
"order": [[1, "desc"]]
});
});
$(document).ready(function () {
$("#group_version_table").DataTable({
"lengthMenu": [[1, 5, 10, 25, -1], [1, 5, 10, 25, "All"]],
"order": [[1, "desc"]]
});
});
......@@ -92,12 +92,12 @@ class TaskWorker(Worker):
def launch_awx_job(resource="job", **kwargs):
"""Launch an AWX job
r"""Launch an AWX job
job_template or inventory_source shall be passed as keyword argument
:param resource: job|workflow_job|inventory_source
:param **kwargs: keyword arguments passed to launch the job
:param \*\*kwargs: keyword arguments passed to launch the job
:returns: A dictionary with information from resource.monitor
"""
rq_job = get_current_job()
......@@ -106,14 +106,10 @@ def launch_awx_job(resource="job", **kwargs):
if job_template is None and inventory_source is None:
current_app.logger.warning("No job_template nor inventory_source passed!")
return "No job_template nor inventory_source passed!"
if (
job_template
in (
current_app.config["AWX_CREATE_VIOC"],
current_app.config["AWX_CREATE_VM"],
)
and not current_app.config.get("AWX_VM_CREATION_ENABLED", False)
):
if job_template in (
current_app.config["AWX_CREATE_VIOC"],
current_app.config["AWX_CREATE_VM"],
) and not current_app.config.get("AWX_VM_CREATION_ENABLED", False):
current_app.logger.info("AWX VM creation is disabled. Not sending any request.")
return "AWX VM creation not triggered"
if not current_app.config.get("AWX_JOB_ENABLED", False):
......
......@@ -136,7 +136,12 @@
{{ field(class_="form-check-input", **kwargs) }}
{{ field.label(class_="form-check-label") }}
</div>
</div>
{% if field.description %}
<small class="form-text text-muted">
{{ field.description|safe }}
</small>
{% endif %}
</div>
{% else %}
{{ field.label(class_="col-sm-" + label_size + " col-form-label") }}
<div class="col-sm-{{ input_size }}">
......@@ -184,7 +189,7 @@
</div>
{%- endmacro %}
{% macro submit_button_with_confirmation(title, message) -%}
{% macro submit_button_with_confirmation(title, message, message2) -%}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#submitModal" }}>
{{ title }}
</button>
......@@ -194,7 +199,7 @@
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">{{ message }}</h6>
<h6 class="modal-title">{{ message }} {% if message2 is defined %}<br/><br/><br/> {{ message2 }} {% endif %}</h6>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
......@@ -277,4 +282,4 @@
</div>
<div class="card-body item-comment">{{ comment.body }}</div>
</div>
{%- endmacro %}
\ No newline at end of file
{%- endmacro %}
......@@ -78,6 +78,7 @@
<script type=text/javascript>
$SCRIPT_ROOT = {{ request.script_root|tojson|safe }};
</script>
<script src="{{ url_for('static', filename='js/history.js') }}"></script>
<script src="{{ url_for('static', filename='js/csentry.js') }}"></script>
{% block csentry_scripts %}{% endblock %}
{% endblock %}