summaryrefslogtreecommitdiff
path: root/src/hydrilla
diff options
context:
space:
mode:
authorWojtek Kosior <koszko@koszko.org>2022-08-23 09:26:16 +0200
committerWojtek Kosior <koszko@koszko.org>2022-09-28 12:54:52 +0200
commit5fefb11ffd50dcda826cd5a256c8b3f650221050 (patch)
treed98721a1b054b894e282668aea79157788903e73 /src/hydrilla
parentc100476b0a34f5098efc96bf2487f09b66b4a6c4 (diff)
downloadhaketilo-hydrilla-5fefb11ffd50dcda826cd5a256c8b3f650221050.tar.gz
haketilo-hydrilla-5fefb11ffd50dcda826cd5a256c8b3f650221050.zip
introduce package installation state enum
Diffstat (limited to 'src/hydrilla')
-rw-r--r--src/hydrilla/proxy/state.py63
-rw-r--r--src/hydrilla/proxy/state_impl/_operations/recompute_dependencies.py7
-rw-r--r--src/hydrilla/proxy/state_impl/concrete_state.py2
-rw-r--r--src/hydrilla/proxy/state_impl/mappings.py178
-rw-r--r--src/hydrilla/proxy/state_impl/repos.py5
-rw-r--r--src/hydrilla/proxy/tables.sql30
-rw-r--r--src/hydrilla/proxy/web_ui/packages.py35
-rw-r--r--src/hydrilla/proxy/web_ui/root.py2
-rw-r--r--src/hydrilla/proxy/web_ui/templates/packages.html.jinja19
-rw-r--r--src/hydrilla/proxy/web_ui/templates/packages__show_single.html.jinja35
-rw-r--r--src/hydrilla/proxy/web_ui/templates/packages__show_single_version.html.jinja38
11 files changed, 314 insertions, 100 deletions
diff --git a/src/hydrilla/proxy/state.py b/src/hydrilla/proxy/state.py
index d975d2f..0c6dff3 100644
--- a/src/hydrilla/proxy/state.py
+++ b/src/hydrilla/proxy/state.py
@@ -65,6 +65,24 @@ class EnabledStatus(Enum):
AUTO_ENABLED = 'A'
NO_MARK = 'N'
+
+class InstalledStatus(Enum):
+ """
+ INSTALLED - Mapping's all files are present and mapping data is not going to
+ be automatically removed.
+
+ NOT_INSTALLED - Some of the mapping's files might be absent. Mapping can be
+ automatically removed if it is orphaned.
+
+ FAILED_TO_INSTALL - Same as "NOT_INSTALLED" but we additionally know that
+ the last automatic attempt to install mapping's files from repository
+ was unsuccessful.
+ """
+ INSTALLED = 'I'
+ NOT_INSTALLED = 'N'
+ FAILED_TO_INSTALL = 'F'
+
+
@dc.dataclass(frozen=True, unsafe_hash=True)
class Ref:
"""...."""
@@ -145,7 +163,7 @@ class RepoDisplayInfo:
class RepoStore(Store[RepoRef]):
@abstractmethod
def get_display_infos(self, include_deleted: bool = False) -> \
- t.Iterable[RepoDisplayInfo]:
+ t.Sequence[RepoDisplayInfo]:
...
@abstractmethod
@@ -159,21 +177,37 @@ class RepoIterationRef(Ref):
pass
+@dc.dataclass(frozen=True)
+class MappingDisplayInfo:
+ ref: 'MappingRef'
+ identifier: str
+ enabled: EnabledStatus
+ active_version_ref: t.Optional['MappingVersionRef']
+ active_version_info: t.Optional[item_infos.MappingInfo]
+
+@dc.dataclass(frozen=True)
+class MappingVersionDisplayInfo:
+ ref: 'MappingVersionRef'
+ info: item_infos.MappingInfo
+ installed: InstalledStatus
+ is_active: bool
+ is_orphan: bool
+ is_local: bool
+ mapping_enabled: EnabledStatus
+
@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
class MappingRef(Ref):
"""...."""
@abstractmethod
- def disable(self, state: 'HaketiloState') -> None:
- """...."""
+ def get_version_display_infos(self) \
+ -> t.Sequence[MappingVersionDisplayInfo]:
...
- @abstractmethod
- def forget_enabled(self, state: 'HaketiloState') -> None:
- """...."""
- ...
class MappingStore(Store[MappingRef]):
- pass
+ @abstractmethod
+ def get_display_infos(self) -> t.Sequence[MappingDisplayInfo]:
+ ...
@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
@@ -185,20 +219,11 @@ class MappingVersionRef(Ref):
...
@abstractmethod
- def get_display_info(self) -> MappingDisplayInfo:
+ def get_display_info(self) -> MappingVersionDisplayInfo:
...
-@dc.dataclass(frozen=True)
-class MappingDisplayInfo:
- ref: MappingVersionRef
- info: item_infos.MappingInfo
- enabled: EnabledStatus
- is_orphan: bool
-
class MappingVersionStore(Store[MappingVersionRef]):
- @abstractmethod
- def get_display_infos(self) -> t.Iterable[MappingDisplayInfo]:
- ...
+ pass
@dc.dataclass(frozen=True, unsafe_hash=True) # type: ignore[misc]
diff --git a/src/hydrilla/proxy/state_impl/_operations/recompute_dependencies.py b/src/hydrilla/proxy/state_impl/_operations/recompute_dependencies.py
index f83eb09..2b18a51 100644
--- a/src/hydrilla/proxy/state_impl/_operations/recompute_dependencies.py
+++ b/src/hydrilla/proxy/state_impl/_operations/recompute_dependencies.py
@@ -31,9 +31,12 @@
# Enable using with Python 3.7.
from __future__ import annotations
+import sqlite3
import typing as t
-import sqlite3
+from urllib.parse import urlparse, urljoin
+
+import requests
from .... import item_infos
from ... import simple_dependency_satisfying as sds
@@ -260,7 +263,7 @@ def _pull_missing_files(cursor: sqlite3.Cursor) -> None:
rows = cursor.fetchall()
- for file_id, sha56, repo_id, repo_url in rows:
+ for file_id, sha256, repo_id, repo_url in rows:
try:
response = requests.get(urljoin(repo_url, f'file/sha256/{sha256}'))
assert response.ok
diff --git a/src/hydrilla/proxy/state_impl/concrete_state.py b/src/hydrilla/proxy/state_impl/concrete_state.py
index fb4e2ca..ec16e11 100644
--- a/src/hydrilla/proxy/state_impl/concrete_state.py
+++ b/src/hydrilla/proxy/state_impl/concrete_state.py
@@ -147,7 +147,7 @@ class ConcreteHaketiloState(base.HaketiloStateWithFields):
return ConcreteRepoIterationRef(repo_iteration_id)
def mapping_store(self) -> st.MappingStore:
- raise NotImplementedError()
+ return mappings.ConcreteMappingStore(self)
def mapping_version_store(self) -> st.MappingVersionStore:
return mappings.ConcreteMappingVersionStore(self)
diff --git a/src/hydrilla/proxy/state_impl/mappings.py b/src/hydrilla/proxy/state_impl/mappings.py
index cce2a36..e5b324d 100644
--- a/src/hydrilla/proxy/state_impl/mappings.py
+++ b/src/hydrilla/proxy/state_impl/mappings.py
@@ -41,29 +41,28 @@ from . import base
@dc.dataclass(frozen=True, unsafe_hash=True)
-class ConcreteMappingVersionRef(st.MappingVersionRef):
- """...."""
+class ConcreteMappingRef(st.MappingRef):
state: base.HaketiloStateWithFields = dc.field(hash=False, compare=False)
- def update_status(self, new_status: st.EnabledStatus) -> None:
- """...."""
- assert new_status != st.EnabledStatus.AUTO_ENABLED
- raise NotImplementedError()
-
- def get_display_info(self) -> st.MappingDisplayInfo:
+ def get_version_display_infos(self) \
+ -> t.Sequence[st.MappingVersionDisplayInfo]:
with self.state.cursor() as cursor:
cursor.execute(
'''
SELECT
- enabled,
+ item_version_id,
definition,
repo,
repo_iteration,
- is_orphan
+ installed,
+ is_active,
+ is_orphan,
+ is_local,
+ enabled
FROM
mapping_display_infos
WHERE
- item_version_id = ?;
+ item_id = ?;
''',
(self.id,)
)
@@ -73,61 +72,154 @@ class ConcreteMappingVersionRef(st.MappingVersionRef):
if rows == []:
raise st.MissingItemError()
- (status_letter, definition, repo, repo_iteration, is_orphan), = rows
+ result = []
- item_info = item_infos.MappingInfo.load(
- definition,
- repo,
- repo_iteration
- )
+ for (item_version_id, definition, repo, repo_iteration, installed,
+ is_active, is_orphan, is_local, status_letter) in rows:
+ ref = ConcreteMappingVersionRef(str(item_version_id), self.state)
- status = st.EnabledStatus(status_letter)
+ item_info = item_infos.MappingInfo.load(
+ definition,
+ repo,
+ repo_iteration
+ )
- return st.MappingDisplayInfo(self, item_info, status, is_orphan)
+ display_info = st.MappingVersionDisplayInfo(
+ ref = ref,
+ info = item_info,
+ installed = st.InstalledStatus(installed),
+ is_active = is_active,
+ is_orphan = is_orphan,
+ is_local = is_local,
+ mapping_enabled = st.EnabledStatus(status_letter)
+ )
+ result.append(display_info)
+
+ return sorted(result, key=(lambda di: di.info))
@dc.dataclass(frozen=True)
-class ConcreteMappingVersionStore(st.MappingVersionStore):
+class ConcreteMappingStore(st.MappingStore):
state: base.HaketiloStateWithFields
- def get(self, id: str) -> st.MappingVersionRef:
- return ConcreteMappingVersionRef(id, self.state)
+ def get(self, id: str) -> st.MappingRef:
+ return ConcreteMappingRef(id, self.state)
- def get_display_infos(self) -> t.Iterable[st.MappingDisplayInfo]:
+ def get_display_infos(self) -> t.Sequence[st.MappingDisplayInfo]:
+ with self.state.cursor() as cursor:
+ cursor.execute(
+ '''
+ SELECT DISTINCT
+ item_id,
+ identifier,
+ CASE WHEN enabled IN ('A', 'E') THEN item_version_id
+ ELSE NULL END,
+ CASE WHEN enabled IN ('A', 'E') THEN definition
+ ELSE NULL END,
+ CASE WHEN enabled IN ('A', 'E') THEN repo
+ ELSE NULL END,
+ CASE WHEN enabled IN ('A', 'E') THEN repo_iteration
+ ELSE NULL END,
+ enabled
+ FROM
+ mapping_display_infos
+ WHERE
+ is_active OR item_version_id IS NULL;
+ '''
+ )
+
+ rows = cursor.fetchall()
+
+ result = []
+
+ for (item_id, identifier, item_version_id, definition, repo,
+ repo_iteration, status_letter) in rows:
+ ref = ConcreteMappingRef(str(item_id), self.state)
+
+ version_ref: t.Optional[st.MappingVersionRef] = None
+ item_info: t.Optional[item_infos.MappingInfo] = None
+
+ if item_version_id is not None:
+ active_version_ref = ConcreteMappingVersionRef(
+ id = str(item_version_id),
+ state = self.state
+ )
+ item_info = item_infos.MappingInfo.load(
+ definition,
+ repo,
+ repo_iteration
+ )
+
+ display_info = st.MappingDisplayInfo(
+ ref = ref,
+ identifier = identifier,
+ enabled = st.EnabledStatus(status_letter),
+ active_version_ref = active_version_ref,
+ active_version_info = item_info
+ )
+
+ result.append(display_info)
+
+ return result
+
+@dc.dataclass(frozen=True, unsafe_hash=True)
+class ConcreteMappingVersionRef(st.MappingVersionRef):
+ state: base.HaketiloStateWithFields = dc.field(hash=False, compare=False)
+
+ def update_status(self, new_status: st.EnabledStatus) -> None:
+ """...."""
+ assert new_status != st.EnabledStatus.AUTO_ENABLED
+ raise NotImplementedError()
+
+ def get_display_info(self) -> st.MappingVersionDisplayInfo:
with self.state.cursor() as cursor:
cursor.execute(
'''
SELECT
- enabled,
- item_version_id,
definition,
repo,
repo_iteration,
- is_orphan
+ installed,
+ is_orphan,
+ is_active,
+ is_local,
+ enabled
FROM
- mapping_display_infos;
- '''
+ mapping_display_infos
+ WHERE
+ item_version_id = ?;
+ ''',
+ (self.id,)
)
- all_rows = cursor.fetchall()
+ rows = cursor.fetchall()
- result = []
+ if rows == []:
+ raise st.MissingItemError()
- for row in all_rows:
- (status_letter, item_version_id, definition, repo, repo_iteration,
- is_orphan) = row
+ (definition, repo, repo_iteration, installed, is_orphan, is_active,
+ is_local, status_letter), = rows
- ref = ConcreteMappingVersionRef(str(item_version_id), self.state)
+ item_info = item_infos.MappingInfo.load(
+ definition,
+ repo,
+ repo_iteration
+ )
- item_info = item_infos.MappingInfo.load(
- definition,
- repo,
- repo_iteration
- )
+ return st.MappingVersionDisplayInfo(
+ ref = self,
+ info = item_info,
+ installed = st.InstalledStatus(installed),
+ is_active = is_active,
+ is_orphan = is_orphan,
+ is_local = is_local,
+ mapping_enabled = st.EnabledStatus(status_letter)
+ )
- status = st.EnabledStatus(status_letter)
- info = st.MappingDisplayInfo(ref, item_info, status, is_orphan)
- result.append(info)
+@dc.dataclass(frozen=True)
+class ConcreteMappingVersionStore(st.MappingVersionStore):
+ state: base.HaketiloStateWithFields
- return sorted(result, key=(lambda di: di.info))
+ def get(self, id: str) -> st.MappingVersionRef:
+ return ConcreteMappingVersionRef(id, self.state)
diff --git a/src/hydrilla/proxy/state_impl/repos.py b/src/hydrilla/proxy/state_impl/repos.py
index f4c7c71..663a450 100644
--- a/src/hydrilla/proxy/state_impl/repos.py
+++ b/src/hydrilla/proxy/state_impl/repos.py
@@ -35,7 +35,6 @@ from __future__ import annotations
import re
import json
import tempfile
-import requests
import sqlite3
import typing as t
import dataclasses as dc
@@ -44,6 +43,8 @@ from urllib.parse import urlparse, urljoin
from datetime import datetime
from pathlib import Path
+import requests
+
from ... import json_instances
from ... import item_infos
from ... import versions
@@ -349,7 +350,7 @@ class ConcreteRepoStore(st.RepoStore):
return ConcreteRepoRef(str(repo_id), self.state)
def get_display_infos(self, include_deleted: bool = False) \
- -> t.Iterable[st.RepoDisplayInfo]:
+ -> t.Sequence[st.RepoDisplayInfo]:
with self.state.cursor() as cursor:
condition: str = 'TRUE'
if include_deleted:
diff --git a/src/hydrilla/proxy/tables.sql b/src/hydrilla/proxy/tables.sql
index 3b3506d..3b84741 100644
--- a/src/hydrilla/proxy/tables.sql
+++ b/src/hydrilla/proxy/tables.sql
@@ -238,28 +238,30 @@ CREATE TABLE payloads(
CREATE VIEW mapping_display_infos
AS
SELECT
+ i.item_id, i.identifier,
CASE WHEN
- ms.enabled = 'N' AND COUNT(p.payload_id) > 0
+ ms.enabled = 'N' AND
+ (ms.required OR COUNT(p.payload_id) > 0)
THEN
'A' -- AUTO_ENABLED mapping
ELSE
ms.enabled
END AS enabled,
- iv.item_version_id, iv.definition,
+ iv.item_version_id, iv.definition, iv.installed,
r.name AS repo,
ri.iteration AS repo_iteration,
- COALESCE(
- r.active_iteration_id != ri.repo_iteration_id,
- TRUE
- ) AND r.repo_id != 1 AS is_orphan
+ COALESCE(r.active_iteration_id, -1) != ri.repo_iteration_id AND
+ r.repo_id != 1 AS is_orphan,
+ r.repo_id = 1 AS is_local,
+ COALESCE(ms.active_version_id, -1) = iv.item_version_id AS is_active
FROM
- item_versions AS iv
- LEFT JOIN payloads AS p
- ON iv.item_version_id = p.mapping_item_id
- JOIN items AS i
- USING (item_id)
+ items AS i
JOIN mapping_statuses AS ms
USING (item_id)
+ JOIN item_versions AS iv
+ USING (item_id)
+ LEFT JOIN payloads AS p
+ ON iv.item_version_id = p.mapping_item_id
JOIN repo_iterations AS ri
USING (repo_iteration_id)
JOIN repos AS r
@@ -267,9 +269,9 @@ FROM
WHERE
i.type = 'M'
GROUP BY
- ms.enabled,
- iv.item_version_id, iv.definition,
- r.name, ri.iteration;
+ ms.enabled, ms.required, ms.active_version_id,
+ iv.item_version_id, iv.definition, iv.installed,
+ r.repo_id, r.active_iteration_id, r.name, ri.iteration;
CREATE TABLE resolved_depended_resources(
payload_id INTEGER,
diff --git a/src/hydrilla/proxy/web_ui/packages.py b/src/hydrilla/proxy/web_ui/packages.py
index 90876aa..d4146a5 100644
--- a/src/hydrilla/proxy/web_ui/packages.py
+++ b/src/hydrilla/proxy/web_ui/packages.py
@@ -94,7 +94,7 @@ def load_from_disk_post() -> werkzeug.Response:
@bp.route('/packages')
def packages() -> werkzeug.Response:
- store = _app.get_haketilo_state().mapping_version_store()
+ store = _app.get_haketilo_state().mapping_store()
html = flask.render_template(
'packages.html.jinja',
@@ -105,11 +105,40 @@ def packages() -> werkzeug.Response:
@bp.route('/packages/view/<string:mapping_id>')
def show_package(mapping_id: str) -> werkzeug.Response:
try:
- store = _app.get_haketilo_state().mapping_version_store()
- display_info = store.get(mapping_id).get_display_info()
+ store = _app.get_haketilo_state().mapping_store()
+ mapping_ref = store.get(str(mapping_id))
+ version_display_infos = mapping_ref.get_version_display_infos()
+
+ active_list = [info for info in version_display_infos if info.is_active]
+
+ active_version_ref = None if active_list == [] else active_list[0].ref
+ active_version_info = None if active_list == [] else active_list[0].info
+
+ display_info = st.MappingDisplayInfo(
+ ref = mapping_ref,
+ identifier = version_display_infos[0].info.identifier,
+ enabled = version_display_infos[0].mapping_enabled,
+ active_version_ref = active_version_ref,
+ active_version_info = active_version_info
+ )
html = flask.render_template(
'packages__show_single.html.jinja',
+ display_info = display_info,
+ version_display_infos = version_display_infos
+ )
+ return flask.make_response(html, 200)
+ except st.MissingItemError:
+ flask.abort(404)
+
+@bp.route('/packages/viewversion/<string:mapping_version_id>')
+def show_package_version(mapping_version_id: str) -> werkzeug.Response:
+ try:
+ store = _app.get_haketilo_state().mapping_version_store()
+ display_info = store.get(mapping_version_id).get_display_info()
+
+ html = flask.render_template(
+ 'packages__show_single_version.html.jinja',
display_info = display_info
)
return flask.make_response(html, 200)
diff --git a/src/hydrilla/proxy/web_ui/root.py b/src/hydrilla/proxy/web_ui/root.py
index 0f42981..67cf6ba 100644
--- a/src/hydrilla/proxy/web_ui/root.py
+++ b/src/hydrilla/proxy/web_ui/root.py
@@ -74,6 +74,8 @@ class WebUIAppImpl(_app.WebUIApp):
]
}
+ self.jinja_env.globals['versions'] = versions
+
self.before_request(authenticate_by_referrer)
for blueprint in [repos.bp, packages.bp]:
diff --git a/src/hydrilla/proxy/web_ui/templates/packages.html.jinja b/src/hydrilla/proxy/web_ui/templates/packages.html.jinja
index bcb8dea..bac774e 100644
--- a/src/hydrilla/proxy/web_ui/templates/packages.html.jinja
+++ b/src/hydrilla/proxy/web_ui/templates/packages.html.jinja
@@ -30,23 +30,18 @@ in a proprietary work, I am not going to enforce this in court.
<h3>{{ _('web_ui.packages.heading') }}</h3>
<ul id="item_list">
{% for info in display_infos %}
- {% if info.info.repo == '<local>' -%}
- {%- set entry_classes = 'package-entry-local' -%}
- {%- else -%}
- {%- set entry_classes = '' -%}
- {%- endif -%}
<li class="{{ entry_classes }}">
<a href="{{ url_for('.show_package', mapping_id=info.ref.id) }}">
<div>
- {{ info.info.long_name }}
+ {{ info.identifier }}
</div>
- <div class="small-print">
- {{ info.info.versioned_identifier }}
- {% if info.info.repo != '<local>' %}
+ {% if info.active_version_ref is not none %}
+ <div class="small-print">
+ {{ versions.version_string(info.active_version_info.version) }}
@
- {{ info.info.repo }}
- {% endif %}
- </div>
+ {{ info.active_version_info.repo }}
+ </div>
+ {% endif %}
</a>
</li>
{% endfor %}
diff --git a/src/hydrilla/proxy/web_ui/templates/packages__show_single.html.jinja b/src/hydrilla/proxy/web_ui/templates/packages__show_single.html.jinja
index eb526c4..17f958c 100644
--- a/src/hydrilla/proxy/web_ui/templates/packages__show_single.html.jinja
+++ b/src/hydrilla/proxy/web_ui/templates/packages__show_single.html.jinja
@@ -23,15 +23,42 @@ in a proprietary work, I am not going to enforce this in court.
{% block title %} {{ _('web_ui.packages.single.title') }} {% endblock %}
{% block style %}
{{ super() }}
+
+ {% include 'include/item_list_style.css.jinja' %}
{% endblock %}
{% block main %}
<h3>
{{
_('web_ui.packages.single.heading.name_{}')
- .format(display_info.info.long_name)
+ .format(display_info.identifier)
}}
</h3>
- <div class="package-identifier">
- {{ display_info.info.versioned_identifier }}
- </div>
+ TODO: add more info...
+ <h4>{{ _('web_ui.packages.single.version_list_heading') }}</h4>
+ <ul id="item_list">
+ {%- for info in version_display_infos %}
+ {% if info.info.repo == '<local>' -%}
+ {%- set entry_classes = 'version-entry-local' -%}
+ {%- else -%}
+ {%- set entry_classes = '' -%}
+ {%- endif %}
+ <li class="{{ entry_classes }}">
+ {%-
+ set href = url_for(
+ '.show_package_version',
+ mapping_version_id = info.ref.id
+ )
+ %}
+ <a href="{{ href }}">
+ <div>
+ {{ versions.version_string(info.info.version) }}
+ {%- if not info.is_local %}
+ @
+ {{ info.info.repo }}
+ {%- endif %}
+ </div>
+ </a>
+ </li>
+ {%- endfor %}
+ </ul>
{% endblock %}
diff --git a/src/hydrilla/proxy/web_ui/templates/packages__show_single_version.html.jinja b/src/hydrilla/proxy/web_ui/templates/packages__show_single_version.html.jinja
new file mode 100644
index 0000000..beedd43
--- /dev/null
+++ b/src/hydrilla/proxy/web_ui/templates/packages__show_single_version.html.jinja
@@ -0,0 +1,38 @@
+{#
+Spdx-License-Identifier: GPL-3.0-or-later OR CC-BY-SA-4.0
+
+
+Proxy web UI package show page.
+
+This file is part of Hydrilla&Haketilo.
+
+Copyright (C) 2022 Wojtek Kosior
+
+Dual licensed under
+* GNU General Public License v3.0 or later and
+* Creative Commons Attribution Share Alike 4.0 International.
+
+You can choose to use either of these licenses or both.
+
+
+I, Wojtek Kosior, thereby promise not to sue for violation of this
+file's licenses. Although I request that you do not make use this code
+in a proprietary work, I am not going to enforce this in court.
+#}
+{% extends "base.html.jinja" %}
+{% block title %} {{ _('web_ui.packages.single_version.title') }} {% endblock %}
+{% block style %}
+ {{ super() }}
+{% endblock %}
+{% block main %}
+ <h3>
+ {{
+ _('web_ui.packages.single_version.heading.name_{}')
+ .format(display_info.info.long_name)
+ }}
+ </h3>
+ <div class="package-identifier">
+ {{ display_info.info.versioned_identifier }}
+ </div>
+ TODO: add more info...
+{% endblock %}