Commit 94ec7696 authored by Romain Loth's avatar Romain Loth

journals view, with new API route for journal facet, dynatable css factorized...

journals view, with new API route for journal facet, dynatable css factorized (applies for all corpora templates) and menu.html providing a corpora navbar to switch between journals and documents
parent c4df8290
......@@ -6,6 +6,7 @@ from gargantext.constants import *
from gargantext.util.validation import validate
from collections import defaultdict
_node_available_fields = ['id', 'parent_id', 'name', 'typename', 'hyperdata', 'ngrams']
_node_default_fields = ['id', 'parent_id', 'name', 'typename']
......@@ -109,3 +110,70 @@ class NodeResource(APIView):
)
session.commit()
return JsonHttpResponse({'deleted': result.rowcount})
class CorpusFacet(APIView):
"""Loop through a corpus node's docs => do counts by a hyperdata field
(url: /api/nodes/<node_id>/facets?hyperfield=<journal>)
"""
# - old url: '^project/(\d+)/corpus/(\d+)/journals/journals.json$',
# - old view: tests.ngramstable.views.get_journals_json()
# - now generalized for various hyperdata field:
# -> journal
# -> publication_year
# -> rubrique
# -> language...
def get(self, request, node_id):
# check that the node is a corpus
# ? faster from cache than: corpus = session.query(Node)...
corpus = cache.Node[node_id]
if corpus.typename != 'CORPUS':
raise ValidationException(
"Only nodes of type CORPUS can accept facet queries" +
" (but this node has type %s)..." % corpus.typename
)
else:
self.corpus = corpus
# check that the hyperfield parameter makes sense
_facet_available_subfields = [
'journal', 'publication_year', 'rubrique',
'language_iso2', 'language_iso3', 'language_name'
]
parameters = get_parameters(request)
# validate() triggers an info message if subfield not in range
parameters = validate(parameters, {'type': dict, 'items': {
'hyperfield': {'type': str, 'range': _facet_available_subfields}
}})
subfield = parameters['hyperfield']
# do the aggregated sum
(xcounts, total) = self._ndocs_by_facet(subfield)
# response
return JsonHttpResponse({
'doc_count' : total,
'by': { subfield: xcounts }
})
def _ndocs_by_facet(self, subfield='journal'):
"""for example on 'journal'
xcounts = {'j good sci' : 25, 'nature' : 32, 'j bla bla' : 1... }"""
xcounts = defaultdict(int)
total = 0
for doc in self.corpus.children(typename='DOCUMENT'):
if subfield in doc.hyperdata:
xcounts[doc.hyperdata[subfield]] += 1
else:
xcounts["_NA_"] += 1
total += 1
# the counts below could also be memoized
# // if subfield not in corpus.aggs:
# // corpus.aggs[subfield] = xcounts
return (xcounts, total)
......@@ -6,4 +6,6 @@ from . import nodes
urlpatterns = [
url(r'^nodes$', nodes.NodeListResource.as_view()),
url(r'^nodes/(\d+)$', nodes.NodeResource.as_view()),
url(r'^nodes/(\d+)/facets$', nodes.CorpusFacet.as_view()),
]
......@@ -46,4 +46,31 @@ def corpus(request, project_id, corpus_id):
@requires_auth
def chart(request, project_id, corpus_id):
user, project, corpus = _get_user_project_corpus(request, project_id, corpus_id)
authorized, user, project, corpus = _get_user_project_corpus(request, project_id, corpus_id)
@requires_auth
def docs_by_journals(request, project_id, corpus_id):
'''
Browse journal titles for a given corpus
NB: javascript in page will GET counts from our api: facets?subfield=journal
# TODO refactor Journals_dyna_charts_and_table.js
'''
# we pass our corpus to mark it's a corpora page
corpus = cache.Node[corpus_id]
# and the project just for project.id in corpusBannerTop
project = cache.Node[project_id]
# rendered page : journals.html
return render(
template_name = 'pages/corpora/journals.html',
request = request,
context = {
'debug': settings.DEBUG,
'date': datetime.now(),
'project': project,
'corpus' : corpus,
'view': 'journals'
},
)
......@@ -23,4 +23,6 @@ urlpatterns = [
url(r'^projects/(\d+)/corpora/(\d+)/?$', corpora.corpus),
url(r'^projects/(\d+)/corpora/(\d+)/chart/?$', corpora.chart),
# corpus by journals
url(r'^projects/(\d+)/corpora/(\d+)/journals/?$', corpora.docs_by_journals),
]
// styles for dynatables
.no-transition {
-webkit-transition: height 0.1s;
-moz-transition: height 0.1s;
-ms-transition: height 0.1s;
-o-transition: height 0.1s;
transition: height 0.1s;
}
th { color: #fff; }
th a {
color: #fff;
font-weight: bold;
font-size: 0.9em;
}
.dynatable-record-count {
font-size: 0.7em;
}
.dynatable-pagination-links {
font-size: 0.7em;
}
#corpusdisplayer {
width:200px;
margin:0 auto;
display:block;
}
function pr(msg) {
console.log(msg)
}
function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie != '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = jQuery.trim(cookies[i]);
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) == (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
var latest,oldest;
var TheBuffer = false
var PossibleActions = []
var action1 = {
"id":"to_delete",
"name": "Delete",
"color":"red"
}
var action2 = {
"id":"to_keep",
"name": "Keep",
"color":"green"
}
var action3 = {
"id":"to_group",
"name": "Group",
"color":"blue"
}
PossibleActions.push(action1)
PossibleActions.push(action2)
PossibleActions.push(action3)
var FlagsBuffer = {}
for(var i in PossibleActions) {
FlagsBuffer[PossibleActions[i].id] = {}
}
var MyTable;
var RecDict={};
var AjaxRecords = []
// D3.js: Interactive timerange variables.
var LineChart = dc.lineChart("#monthly-move-chart");
var volumeChart = dc.barChart("#monthly-volume-chart");
function Push2Buffer( NewVal ) {
if ( TheBuffer == false) {
if( ! NewVal ) {
var limits = [ oldest , latest ];
NewVal = limits;
}
console.log( " - - - - - - " )
console.log( "\tchanging to:" )
console.log( NewVal )
TheBuffer = NewVal;
Final_UpdateTable( "changerange" )
console.log( "- - - - - - -\n" )
return 1;
}
if ( TheBuffer != false ) {
var past = TheBuffer[0]+"_"+TheBuffer[1]
if( ! NewVal ) {
var limits = [ oldest , latest ];
NewVal = limits;
}
var now = NewVal[0]+"_"+NewVal[1]
if ( past != now ) {
console.log( " - - - - - - " )
console.log( "\tchanging to:" )
console.log( NewVal )
TheBuffer = NewVal;
Final_UpdateTable( "changerange" )
console.log( "- - - - - - -\n" )
}
return 1;
}
}
function Final_UpdateTable( action ) {
// (1) Identifying if the button is collapsed:
var isCollapsed=false;
var tableClass = $("#journal_table").attr("class")
//\bcollapse\b
if(tableClass.indexOf(" collapse ")>-1) {
isCollapsed=true;
}
var UpdateTable = false
if ( (action == "click" && !isCollapsed) || (action=="changerange" && isCollapsed) ) {
UpdateTable = true;
$("#corpusdisplayer").html("Close Folder")
} else $("#corpusdisplayer").html("Open Folder")
pr("update table??: "+UpdateTable)
if ( ! UpdateTable ) return false; //stop whatever you wanted to do.
var TimeRange = AjaxRecords;
var dataini = (TheBuffer[0])?TheBuffer[0]:oldest;
var datafin = (TheBuffer[1])?TheBuffer[1]:latest;
pr("show me the pubs of the selected period")
pr("\tfrom ["+dataini+"] to ["+datafin+"]")
pr("\tfrom ["+oldest+"] to ["+latest+"]")
TimeRange = []
for (var i in AjaxRecords) {
if(AjaxRecords[i].score>=dataini && AjaxRecords[i].score<=datafin){
// pr( AjaxRecords[i].date+" : "+AjaxRecords[i].id )
TimeRange.push(AjaxRecords[i])
}
}
MyTable = $('#my-ajax-table').dynatable({
dataset: {
records: TimeRange
},
features: {
pushState: false,
// sort: false
},
writers: {
_rowWriter: ulWriter
// _cellWriter: customCellWriter
}
});
MyTable.data('dynatable').settings.dataset.originalRecords = []
MyTable.data('dynatable').settings.dataset.originalRecords = TimeRange;
MyTable.data('dynatable').paginationPage.set(1);
MyTable.data('dynatable').process();
}
// STEP 01:
// Get all the duplicates using the Django-Garg API
var current_docs = {}
var BIS_dict = {}
var url_elems = window.location.href.split("/")
var url_mainIDs = {}
for(var i=0; i<url_elems.length; i++) {
// if the this element is a number:
if(url_elems[i]!="" && !isNaN(Number(url_elems[i]))) {
url_mainIDs[url_elems[i-1]] = Number(url_elems[i]);
}
}
var theurl = "/api/nodes/"+url_mainIDs["corpus"]+"/children/duplicates?keys=title&limit=9999"
// $.ajax({
// url: theurl,
// success: function(data) {
// bisarray = data.data
// for(var i in bisarray) {
// untitlebis = bisarray[i].values
// BIS_dict[untitlebis[0]] = [bisarray[i].count , 0];// [ total amount , removed ]
// }
// pr(BIS_dict)
// if(Object.keys(BIS_dict).length>0) $("#delAll").css("visibility", "visible"); $("#delAll").show();
// }
// });
function getRecord(rec_id) {
return MyTable.data('dynatable').settings.dataset.originalRecords[rec_id];
// return AjaxRecords[rec_id]
}
function getRecords() {
return MyTable.data('dynatable').settings.dataset.originalRecords;
}
function transformContent2(rec_id) {
// pr("\t\ttransformContent2: "+rec_id)
var elem = AjaxRecords[rec_id];
var result = {}
// console.log("\t\t\telement flag : "+elem["flag"])
if (elem["flag"]) {
result["id"] = elem["id"]
result["score"] = '<div class="'+elem["flag"]+'"><i>'+elem["score"]+'</div>'
result["name"] = '<div class="'+elem["flag"]+'"><i>'+elem["name"]+'</div>'
result["flag"] = '<input id='+rec_id+' onclick="overRide(this)" type="checkbox" checked/>'
} else {
result["id"] = elem["id"]
result["score"] = elem["score"]
result["name"] = elem["name"]
result["flag"] = '<input id='+rec_id+' onclick="overRide(this)" type="checkbox"/>'
}
return result;
}
function overRide(elem) {
var id = elem.id
var current_flag = $("input[type='radio'][name='radios']:checked").val()
var this_newflag = (current_flag==AjaxRecords[id]["flag"])?false:current_flag
console.log("striking: "+id+" | this-elem_flag: "+AjaxRecords[id]["flag"]+" | current_flag: "+current_flag)
console.log("\t so the new flag is: "+this_newflag)
AjaxRecords[id]["flag"] = Mark_NGram ( id , AjaxRecords[id]["flag"] , this_newflag );
MyTable.data('dynatable').dom.update();
}
function transformContent(rec_id , header , content) {
if(header=="flag") {
// pr("\t\ttransformContent1: "+rec_id)
if(content==true) return '<input id='+rec_id+' onclick="overRide(this)" type="checkbox" checked/>'
if(content==false) return '<input id='+rec_id+' onclick="overRide(this)" type="checkbox"/>'
} else return content;
}
function Mark_NGram( ngram_id , old_flag , new_flag ) {
if(new_flag){
for(var f in FlagsBuffer) {
if( new_flag==f )
FlagsBuffer[f][ngram_id] = true;
else
delete FlagsBuffer[f][ngram_id];
}
} else {
delete FlagsBuffer[ old_flag ][ngram_id];
}
return new_flag;
}
//generic enough
function ulWriter(rowIndex, record, columns, cellWriter) {
// pr("\tulWriter: "+record.id)
var tr = '';
var cp_rec = {}
if(!MyTable) {
for(var i in record) {
cp_rec[i] = transformContent(RecDict[record.id], i , record[i])
}
} else {
// pr("\t\tbfr transf2: rec_id="+record.id+" | arg="+RecDict[record.id])
cp_rec = transformContent2(RecDict[record.id])
}
// grab the record's attribute for each column
// console.log("\tin ulWriter:")
// console.log(record)
for (var i = 0, len = columns.length; i < len; i++) {
tr += cellWriter(columns[i], cp_rec);
}
var data_id = RecDict[record.id]
return '<tr data-stuff='+data_id+'>' + tr + '</tr>';
}
function Main_test( data , initial) {
var DistributionDict = {}
for(var i in DistributionDict)
delete DistributionDict[i];
delete DistributionDict;
DistributionDict = {}
AjaxRecords = []
var FirstScore = initial;
var arrayd3 = []
// div_table += "\t"+"\t"+"\t"+'<input type="checkbox" id="multiple_selection" onclick="SelectAll(this);" /> Select'+"\n"
$("#div-table").html("")
$("#div-table").empty();
var div_table = '<p align="right">'+"\n"
div_table += '<table id="my-ajax-table" class="table table-bordered table-hover">'+"\n"
div_table += "\t"+'<thead>'+"\n"
div_table += "\t"+"\t"+'<th data-dynatable-column="name">Title</th>'+"\n"
div_table += "\t"+"\t"+'<th data-dynatable-column="score" data-dynatable-sorts="score">No. Pubs</th>'+"\n"
// div_table += "\t"+"\t"+'<th id="score_column_id" data-dynatable-sorts="score" data-dynatable-column="score">Score</th>'+"\n"
div_table += "\t"+"\t"+'</th>'+"\n"
div_table += "\t"+'</thead>'+"\n"
div_table += "\t"+'<tbody>'+"\n"
div_table += "\t"+'</tbody>'+"\n"
div_table += '</table>'+"\n"
div_table += '</p>';
$("#div-table").html(div_table)
// $("#stats").html(div_stats)
var ID = 0
console.log("Filling 'AjaxRecords' from argument 'data'")
for(var i in data) {
console.log(" >>" + i)
// var le_ngram = data.ngrams[i]
var orig_id = ID
var arr_id = parseInt(ID)
RecDict[orig_id] = arr_id;
var url_title = encodeURIComponent(i)//.replace(" ","+")
// url_title = i.replace(" ","+")
var node_info = {
"id" : ID,
"name" : '<a target=_blank href="http://google.com/search?q='+url_title+'">'+i+'</a>',
"score": data[i],
}
AjaxRecords.push(node_info)
if ( ! DistributionDict[node_info.score] ) DistributionDict[node_info.score] = 0;
DistributionDict[node_info.score]++;
ID++;
}
// console.log("The Distribution!:")
// console.log(Distribution)
var DistributionList = []
var min_occ=99999,max_occ=-1,min_frec=99999,max_frec=-1;
for(var i in DistributionDict) {
var info = {
"x_occ":Number(i),
"y_frec":DistributionDict[i]
}
DistributionList.push(info)
if(info.x_occ > max_occ) max_occ = info.x_occ
if(info.x_occ < min_occ) min_occ = info.x_occ
if(info.y_frec > max_frec) max_frec = info.y_frec
if(info.y_frec < min_frec) min_frec = info.y_frec
}
oldest = Number(min_occ);
latest = Number(max_occ);
var ndx = false;
ndx = crossfilter();
ndx.add(DistributionList);
// x_occs = ndx.dimension(dc.pluck('x_occ'));
var x_occs = ndx.dimension(function (d) {
return d.x_occ;
});
var y_frecs = x_occs.group().reduceSum(function (d) {
return d.y_frec;
});
console.log("scores: [ "+min_occ+" , "+max_occ+" ] ")
console.log("frecs: [ "+min_frec+" , "+max_frec+" ] ")
LineChart
.width(800)
.height(150)
.margins({top: 10, right: 50, bottom: 25, left: 40})
.group(y_frecs)
.dimension(x_occs)
.transitionDuration(500)
.x(d3.scale.linear().domain([min_occ,max_occ+min_occ]))
// .y(d3.scale.log().domain([min_frec/2,max_frec*2]))
.renderArea(true)
// .valueAccessor(function (d) {
// return d.value;
// })
// .stack(y_frecs, function (d) {
// return d.value;
// })
// .ordinalColors(d3.scale.category10())
.elasticY(true)
// .round(dc.round.floor)
.renderHorizontalGridLines(true)
.renderVerticalGridLines(true)
// .colors('red')
// .interpolate("monotone")
// .renderDataPoints({radius: 2, fillOpacity: 0.8, strokeOpacity: 0.8})
.brushOn(false)
.title(function (d) {
var value = d.value.avg ? d.value.avg : d.value;
if (isNaN(value)) value = 0;
return value+" journals with "+Number(d.key)+" publications";
})
.xAxis();
LineChart.yAxis().ticks(5)
LineChart.render()
volumeChart.width(800)
.height(100)
.margins({top: 30, right: 50, bottom: 20, left: 40})
.dimension(x_occs)
.group(y_frecs)
.centerBar(true)
.gap(5)
.x(d3.scale.linear().domain([min_occ/2,max_occ+min_occ]))
.y(d3.scale.sqrt().domain([min_frec/2,max_frec+min_frec]))
// .elasticY(true)
// // .round(d3.time.month.round)
// // .xUnits(d3.time.months)
.renderlet(function (chart) {
chart.select("g.y").style("display", "none");
LineChart.filter(chart.filter());
console.log("lalaal moveChart.focus(chartfilt);")
})
.on("filtered", function (chart) {
dc.events.trigger(function () {
var chartfilt = chart.filter()
// tricky part: identifying when the moveChart changes.
if(chartfilt) {
Push2Buffer ( chart.filter() )
} else {
if(TheBuffer) {
Push2Buffer ( false )
}
}
LineChart.focus(chartfilt);
});
})
.xAxis()
volumeChart.yAxis().ticks(5)
volumeChart.render()
LineChart.filterAll();
volumeChart.filterAll();
dc.redrawAll();
MyTable = []
MyTable = $('#my-ajax-table').dynatable({
dataset: {
records: AjaxRecords
},
features: {
pushState: false,
// sort: false //i need to fix the sorting function... the current one just sucks
},
writers: {
_rowWriter: ulWriter
// _cellWriter: customCellWriter
}
});
console.log("After table 1")
// MyTable.data('dynatable').settings.dataset.records = []
// MyTable.data('dynatable').settings.dataset.originalRecords = []
// MyTable.data('dynatable').settings.dataset.originalRecords = AjaxRecords;
MyTable.data('dynatable').sorts.clear();
MyTable.data('dynatable').sorts.add('score', 0) // 1=ASCENDING,
MyTable.data('dynatable').process();
MyTable.data('dynatable').paginationPage.set(1);
// MyTable.data('dynatable').process();
// MyTable.data('dynatable').sorts.clear();
MyTable.data('dynatable').process();
console.log("After table 2")
// // // $("#score_column_id").children()[0].text = FirstScore
// // // // MyTable.data('dynatable').process();
if ( $(".imadiv").length>0 ) return 1;
$('<br><br><div class="imadiv"></div>').insertAfter(".dynatable-per-page")
$(".dynatable-record-count").insertAfter(".imadiv")
$(".dynatable-pagination-links").insertAfter(".imadiv")
console.log("After table 3")
return "OK"
}
$("#corpusdisplayer").hide()
console.log(window.location.href)
// match corpus_id in the url
var corpus_id ;
rematch = window.location.href.match(/corpora\/(\d+)\/journals\/?$/)
if (rematch) {
corpus_id = rematch[1] ;
$.ajax({
url: window.location.origin
+ "/api/nodes/"
+ corpus_id
+ "/facets?hyperfield=journal",
success: function(data){
console.log(data)
$("#content_loader").remove()
// // Initializing the Charts and Table
var result = Main_test( data.by.journal , "FirstScore" )
console.log( result )
$("#corpusdisplayer").show()
$("#content_loader").remove()
$("#corpusdisplayer").click()
}
});
}
else {
}
......@@ -12,37 +12,12 @@
<link rel="stylesheet" type="text/css" href="{% static "css/jquery/jquery.easy-pie-chart.css"%}">
<link rel="stylesheet" type="text/css" href="{% static "css/jquery/jquery.dynatable.css"%}"/>
<link rel="stylesheet" type="text/css" href="{% static "css/gargantext/tables.css"%}"/>
<script type="text/javascript" src="{% static "js/d3/d3.js"%}"></script>
<script type="text/javascript" src="{% static "js/d3/dc.js"%}"></script>
<script type="text/javascript" src="{% static "js/d3/crossfilter.js"%}"></script>
<style>
.no-transition {
-webkit-transition: height 0.1s;
-moz-transition: height 0.1s;
-ms-transition: height 0.1s;
-o-transition: height 0.1s;
transition: height 0.1s;
}
th { color: #fff; }
th a {
color: #fff;
font-weight: normal;
font-style: italic;
font-size: 0.9em;
}
.dynatable-record-count {
font-size: 0.7em;
}
.dynatable-pagination-links {
font-size: 0.7em;
}
</style>
{% endblock %}
......
{% extends "pages/menu.html" %}
{% block css %}
{% load staticfiles %}
<link rel="stylesheet" type="text/css" href="{% static "css/bootstrap.css" %}">
<link rel="stylesheet" type="text/css" href="{% static "css/d3/dc.css"%}"/>
<link rel="stylesheet" type="text/css" href="{% static "css/jquery/jquery.dynatable.css"%}"/>
<link rel="stylesheet" type="text/css" href="{% static "css/gargantext/tables.css"%}"/>
<script type="text/javascript" src="{% static "js/d3/d3.js"%}"></script>
<script type="text/javascript" src="{% static "js/d3/crossfilter.js"%}"></script>
<script type="text/javascript" src="{% static "js/d3/dc.js"%}"></script>
{% endblock %}
{% block content %}
<div class="container">
<div class="jumbotron">
<div class="row">
<div id="monthly-move-chart">
<center>
Select a frequency group in the chart with blue bars to zoom in
<p align="center">
<a class="btn btn-xs btn-default" role="button" href="/chart/corpus/{{ corpus.id }}/data.csv">Save</a>
<a class="btn btn-xs btn-default" href="javascript:volumeChart.filterAll();dc.redrawAll();">Reset</a></p>
<!-- <p style="font-size:70%">
<b>x</b>: amount of documents for the journal
<b>y</b>: number of journals with that amount
</p> -->
<div class="clearfix"></div>
</center>
</div>
</div>
<div class="row">
<div id="monthly-volume-chart"></div>
</div>
<div id="content_loader">
<br>
<center>
<!-- <img width="10%" src="{% static "img/ajax-loader.gif"%}"></img> -->
</center>
<br>
</div>
<div class="row">
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-target="#journal_table" href="#">
<!-- Final_UpdateTable redraws the dynatable if necessary -->
<p id="corpusdisplayer" onclick='Final_UpdateTable("click")' class="btn btn-primary btn-lg">
Open Folder
</p>
</a>
</h4>
</div>
<div id="journal_table" class="panel-collapse collapse in no-transition" role="tabpanel">
<div class="panel-body">
<div id="div-table">
<!-- (table id="my-ajax-table") dynamically set by Journals_dyna_chart_and_table -->
</div>
</div>
</div> <!-- /div panel-collapse -->
</div> <!-- /div panel -->
</div> <!-- /row with the dynatable panels -->
</div> <!-- /jumbotron -->
</div> <!-- /container -->
<script type="text/javascript" src="{% static "js/jquery/jquery.min.js" %}"></script>
<script src="{% static "js/bootstrap/bootstrap.min.js" %}"></script>
<script type="text/javascript" src="{% static "js/jquery/jquery.dynatable.js" %}"></script>
<!-- custom-lib for dynatable.js and dc.js -->
<script type="text/javascript" src="{% static "js/gargantext/Journals_dyna_chart_and_table.js" %}"></script>
{% endblock %}
......@@ -58,6 +58,23 @@
<!--/.nav-collapse -->
{% block corpusBannerTop %}
{% if corpus %}
<div class="container theme-showcase">
<div class="jumbotron" style="margin-bottom:0">
<div id="corpusbar" class="row">
<div class="col-md-6">
<div class="btn-group btn-group-justified">
<center>
<a type="button" class="btn btn-default {% if view == "documents" %}active{%endif%}" href="/projects/{{project.id}}/corpora/{{ corpus.id }}/">Documents</a>
<a type="button" class="btn btn-default {% if view == "journals" %}active{%endif%}" href="/projects/{{project.id}}/corpora/{{ corpus.id }}/journals">Journals</a>
</center>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment