Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Semantic search storage and routing #495

Merged
merged 36 commits into from
Aug 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
4d53ed1
Add backend model for storing semantic queries in JSON form (#486)
jgonggrijp Jul 1, 2021
6205a5f
Merge branch 'develop' into feature/sem-search-routing
jgonggrijp Jul 6, 2021
bf3d142
For future use, also store who created each query and when (#486)
jgonggrijp Jul 8, 2021
b2173fb
Add a preliminary serializer for the backend SemanticQuery model (#486)
jgonggrijp Jul 13, 2021
64c919d
Strip trailing whitespace from backend/items/views.py
jgonggrijp Jul 13, 2021
e57b0c1
Add preliminary DRF viewset for SemanticQuery model (#486)
jgonggrijp Jul 13, 2021
234fe16
Register the SemanticQueryViewSet with the api root (#486)
jgonggrijp Jul 13, 2021
e6d7e94
Add the abstractDeepEqual utility (#486)
jgonggrijp Jul 16, 2021
26fff8e
Add SemanticQuery frontend model (#486)
jgonggrijp Jul 14, 2021
2f9f39d
Apply toJSON recursively in SemanticQuery.toJSON (#486)
jgonggrijp Jul 16, 2021
caeeb6d
Use .toJSON in abstractDeepEqual as well (#486)
jgonggrijp Jul 16, 2021
8bf711f
Make abstractDeepEqual more robust against asymmetries (#486)
jgonggrijp Jul 16, 2021
d708149
Comment the frontend SemanticQuery model and tests (#486)
jgonggrijp Jul 16, 2021
994d67e
Add semantic query collection type for backend URL derivation (#486)
jgonggrijp Jul 27, 2021
f3560a8
Make SemanticQuery the model type of SemanticSearchView (#486)
jgonggrijp Jul 27, 2021
2f0ab0a
Add an <input> to the SemanticSearchView for the query label (#486)
jgonggrijp Jul 27, 2021
8a3b403
Reset query id on semantic search form changes (#486)
jgonggrijp Jul 28, 2021
0d0d95a
Consistently mediate all search events through welcome view (#486)
jgonggrijp Jul 28, 2021
1dca5b2
Put WelcomeView in charge of managing the SemanticSearchView (#486)
jgonggrijp Jul 28, 2021
831a983
Automatically set the creator on newly posted semantic queries (#486)
jgonggrijp Jul 28, 2021
4f4b8d4
Add global collection of semantic queries (#486)
jgonggrijp Jul 28, 2021
7b8028a
Fix an oversight in Model.prototype.url (#486)
jgonggrijp Jul 28, 2021
5536948
Save semantic query to backend if new (#486)
jgonggrijp Jul 28, 2021
23a12da
Pass semantic query model to SearchResultListView (#486)
jgonggrijp Jul 28, 2021
4b24776
Include serial in semantic search results route (#486)
jgonggrijp Jul 28, 2021
005cce7
Enable announceRoute utility to work with numeric ids (#486)
jgonggrijp Jul 28, 2021
c99f2ed
Take into account that the semantic query may arrive async (#486)
jgonggrijp Jul 29, 2021
0ecd3c8
Report the correct route for semantic search results (#486)
jgonggrijp Jul 28, 2021
1864a43
Enable semantic search deep linking (close #486)
jgonggrijp Jul 29, 2021
15d6a58
Only destroy select2 element if initialized (#486 #465 #426)
jgonggrijp Jul 29, 2021
f314315
Restore selection in FilterInput async (#486)
jgonggrijp Jul 29, 2021
9c1ba6c
Ensure that preselected options appear with a label (#486)
jgonggrijp Jul 29, 2021
b1b0ce8
Only include semantic query in response on retrieve (#486)
jgonggrijp Jul 29, 2021
3756a53
Restrict semantic query API listing to user's own (#486 #163)
jgonggrijp Jul 29, 2021
bbf1977
Add a luxurious backend admin page for the SemanticQuery model (#486)
jgonggrijp Jul 30, 2021
6209c64
Update the semantic search README (#486)
jgonggrijp Jul 30, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion backend/items/admin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
from django.contrib import admin

# Register your models here.
from .models import SemanticQuery


@admin.register(SemanticQuery)
class SemanticQueryAdmin(admin.ModelAdmin):
date_hierarchy = 'created'
fields = ('label', 'creator', 'created', 'query')
readonly_fields = ('created', 'query')
autocomplete_fields = ('creator',)
search_fields = ('label',)
list_display = ('id', 'label', 'creator', 'created')
list_display_links = ('id', 'label')
list_filter = ('created', 'creator')
show_full_result_count = False

def view_on_site(self, obj):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't know this method, very useful!

return '/explore/query/{}'.format(obj.id)
22 changes: 22 additions & 0 deletions backend/items/migrations/0004_semanticquery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Generated by Django 2.0.13 on 2021-07-01 15:41

import django.contrib.postgres.fields.jsonb
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('items', '0003_init_permissions'),
]

operations = [
migrations.CreateModel(
name='SemanticQuery',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('label', models.CharField(blank=True, max_length=100)),
('query', django.contrib.postgres.fields.jsonb.JSONField()),
],
),
]
28 changes: 28 additions & 0 deletions backend/items/migrations/0005_auto_20210708_1249.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 2.0.13 on 2021-07-08 10:49

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('items', '0004_semanticquery'),
]

operations = [
migrations.AddField(
model_name='semanticquery',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='semanticquery',
name='creator',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL),
),
]
16 changes: 16 additions & 0 deletions backend/items/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
from django.db import models
from django.contrib.postgres.fields import JSONField
from django.conf import settings

from rdf.baseclasses import BaseCounter
from .constants import ITEMS_NS, ITEMS_HISTORY_NS

Expand All @@ -8,3 +12,15 @@ class ItemCounter(BaseCounter):

class EditCounter(BaseCounter):
namespace = ITEMS_HISTORY_NS


class SemanticQuery(models.Model):
label = models.CharField(blank=True, max_length=100)
query = JSONField()
creator = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL,
)
created = models.DateTimeField(auto_now_add=True)

def __str__(self):
return self.label or str(self.id)
20 changes: 20 additions & 0 deletions backend/items/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from rest_framework import serializers

from .models import SemanticQuery


class SemanticQuerySerializerFull(serializers.ModelSerializer):
class Meta:
model = SemanticQuery
fields = ['id', 'label', 'query']


class SemanticQuerySerializer(serializers.ModelSerializer):
class Meta(SemanticQuerySerializerFull.Meta):
extra_kwargs = {
'query': {'write_only': True}
}

def create(self, validated_data):
validated_data['creator'] = self.context['request'].user
return super().create(validated_data)
31 changes: 27 additions & 4 deletions backend/items/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

from django.http import FileResponse, HttpResponse

from rest_framework.decorators import action, api_view, renderer_classes
from rest_framework.decorators import action, api_view, renderer_classes
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.response import Response
from rest_framework.status import *
from rest_framework.exceptions import ValidationError, NotFound, PermissionDenied
from rest_framework.viewsets import GenericViewSet
from rest_framework.mixins import CreateModelMixin, ListModelMixin, RetrieveModelMixin

from rdflib import Graph, URIRef, BNode, Literal
from rdflib.query import ResultException
Expand All @@ -24,8 +26,9 @@
from sources import namespace as source
from . import namespace as my
from .graph import graph, history
from .models import ItemCounter, EditCounter
from .models import ItemCounter, EditCounter, SemanticQuery
from .permissions import *
from .serializers import SemanticQuerySerializer, SemanticQuerySerializerFull

MUST_SINGLE_BLANK_400 = 'POST requires exactly one subject which must be a blank node.'
MUST_EQUAL_IDENTIFIER_400 = 'PUT must affect exactly the resource URI.'
Expand Down Expand Up @@ -267,7 +270,7 @@ class ItemSuggestion(RDFView):
def graph(self):
return graph()

def get_graph(self, request, **kwargs):
def get_graph(self, request, **kwargs):
items = self.graph()
if not request.user.has_perm('rdflib_django.view_all_annotations'):
user, now = submission_info(request)
Expand All @@ -287,7 +290,7 @@ class ItemsOfCategory(RDFView):
def graph(self):
return graph()

def get_graph(self, request, category, **kwargs):
def get_graph(self, request, category, **kwargs):
items = self.graph()
bindings = {'category': ontology[category]}
if not request.user.has_perm('rdflib_django.view_all_annotations'):
Expand All @@ -304,3 +307,23 @@ def get_graph(self, request, category, **kwargs):
break
[user_items.add(triple) for triple in items.triples((s, None, None))]
return user_items


class SemanticQueryViewSet(
CreateModelMixin, ListModelMixin, RetrieveModelMixin,
GenericViewSet,
):
queryset = SemanticQuery.objects.all()

def get_queryset(self):
if self.action == 'list':
if self.request.user.is_anonymous:
return self.queryset.none()
return self.queryset.filter(creator=self.request.user)
return self.queryset

def get_serializer_class(self):
if self.action == 'retrieve':
return SemanticQuerySerializerFull
else:
return SemanticQuerySerializer
2 changes: 2 additions & 0 deletions backend/readit/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
from nlp_ontology import NLP_ONTOLOGY_ROUTE
from sources import SOURCES_ROUTE
from items import ITEMS_ROUTE
from items.views import SemanticQueryViewSet
from sparql import SPARQL_ROUTE
from .index import index, specRunner
from .utils import decode_and_proxy
from feedback.views import FeedbackViewSet

api_router = routers.DefaultRouter() # register viewsets with this router
api_router.register(r'feedback', FeedbackViewSet, basename='feedback')
api_router.register(r'query', SemanticQueryViewSet, basename='query')

urlpatterns = [
path('admin/', admin.site.urls),
Expand Down
23 changes: 14 additions & 9 deletions frontend/src/aspects/exploration.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { partial, isString } from 'lodash';

import channel from '../explorer/explorer-radio';
import explorerChannel from '../explorer/explorer-radio';
import * as act from '../explorer/route-actions';
import SuggestionsPanel from '../panel-suggestions/suggestions-view';
import semChannel from '../semantic-search/radio';
import deparam from '../utilities/deparam';
import router from '../global/exploration-router';
import mainRouter from '../global/main-router';
import explorer from '../global/explorer-view';
import controller from '../global/explorer-controller';
import welcomeView from '../global/welcome-view';
import semanticSearchView from '../global/semantic-search';
import SuggestionsPanel from '../panel-suggestions/suggestions-view';
import deparam from '../utilities/deparam';

const browserHistory = window.history;
let suggestionsPanel: SuggestionsPanel;
Expand All @@ -30,6 +30,7 @@ function deepRoute(obtainAction, resetAction) {
const sourceRoute = partial(deepRoute, act.getSource);
const itemRoute = partial(deepRoute, act.getItem);
const queryRoute = partial(deepRoute, deparam);
const semRoute = partial(deepRoute, act.getQuery);
function annoRoute(resetAction) {
return (sourceSerial, itemSerial) => explorer.scrollOrAction(
browserHistory.state,
Expand Down Expand Up @@ -58,9 +59,10 @@ router.on({
'route:item:external:edit': itemRoute(act.itemWithEditExternal),
'route:item:annotations': itemRoute(act.itemWithOccurrences),
'route:search:results:sources': queryRoute(act.searchResultsSources),
'route:search:results:semantic': semRoute(act.searchResultsSemantic),
});

channel.on({
explorerChannel.on({
'sourceview:showAnnotations': controller.reopenSourceAnnotations,
'sourceview:hideAnnotations': controller.unlistSourceAnnotations,
'sourceview:textSelected': controller.selectText,
Expand All @@ -84,14 +86,17 @@ channel.on({
'searchResultList:itemClicked': controller.openSearchResult,
'searchResultList:itemClosed': controller.closeToRight,
}, controller);
channel.on('currentRoute', (route, panel) => {
explorerChannel.on('currentRoute', (route, panel) => {
router.navigate(route);
// Amend the state that Backbone.history just pushed with the cid of the
// panel.
browserHistory.replaceState(panel.cid, document.title);
});

welcomeView.on({'search:start': controller.resetSourceListFromSearchResults}, controller);
welcomeView.on({'suggestions:show': controller.showSuggestionsPanel}, controller);
semChannel.on('presentQuery', welcomeView.presentSemanticQuery, welcomeView);

semanticSearchView.on('search', controller.resetSemanticSearch, controller);
welcomeView.on({
'search:textual': controller.resetSourceListFromSearchResults,
'search:semantic': controller.resetSemanticSearch,
'suggestions:show': controller.showSuggestionsPanel,
}, controller);
6 changes: 2 additions & 4 deletions frontend/src/aspects/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import explorationRouter from '../global/exploration-router';
import userFsm from '../global/user-fsm';
import explorerView from '../global/explorer-view';
import notFoundView from '../global/notfound-view';
import semanticSearchView from '../global/semantic-search';

history.once('route notfound', () => {
menuView.render().$el.appendTo('#header');
Expand Down Expand Up @@ -57,7 +56,6 @@ menuView.on('feedback', () => feedbackView.render().$el.appendTo('body'));

feedbackView.on('close', () => feedbackView.$el.detach());

welcomeView.on('search:start', () => userFsm.handle('explore'));
welcomeView.on('search:textual', () => userFsm.handle('explore'));
welcomeView.on('search:semantic', () => userFsm.handle('explore'));
welcomeView.on('suggestions:show', () => userFsm.handle('explore'));

semanticSearchView.on('search', () => userFsm.handle('explore'));
4 changes: 3 additions & 1 deletion frontend/src/core/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ export default class Model extends BackboneModel {
extend(Model.prototype, {
sync: syncWithCSRF,
url: function() {
const superUrl = BackboneModel.prototype.url.apply(this);
if (this.isNew()) return superUrl;
// Django requires the trailing slash, so add it.
return BackboneModel.prototype.url.apply(this) + '/';
return superUrl + '/';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't follow what's happening here. When a new model is made it shouldn't append the trailing slash?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. By default, Backbone will generate /path/to/api/endpoint/ for saving new models and /path/to/api/endpoint/id for fetching or updating existing models. In the first case, we don't want to append a second slash. In the second case, we do want to finish with a slash because Django will otherwise interpret it as a frontend route.

},
});

Expand Down
11 changes: 8 additions & 3 deletions frontend/src/explorer/explorer-event-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
isOntologyClass,
} from '../utilities/linked-data-utilities';
import { itemsForSourceQuery } from '../sparql/compile-query';
import SemanticQuery from '../semantic-search/model';
import modelToQuery from '../semantic-search/modelToQuery';

interface ExplorerEventController extends Events { }
Expand Down Expand Up @@ -87,12 +88,16 @@ class ExplorerEventController {
this.explorerView.reset(sourceListPanel);
}

resetSemanticSearch(model: Model): SearchResultListView {
const query = modelToQuery(model);
resetSemanticSearch(model: SemanticQuery): SearchResultListView {
const items = new ItemGraph();
items.sparqlQuery(query);
model.when(
'query',
(model, query) => items.sparqlQuery(modelToQuery(query))
);
if (model.isNew()) model.save();
const collection = new FlatItemCollection(items);
const resultsView = new SearchResultListView({
model,
collection,
selectable: false,
});
Expand Down
14 changes: 14 additions & 0 deletions frontend/src/explorer/route-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { Namespace } from '../common-rdf/vocabulary';
import ldChannel from '../common-rdf/radio';
import Node from '../common-rdf/node';
import FlatItem from '../common-adapters/flat-item-model';
import semChannel from '../semantic-search/radio';
import SemanticQuery from '../semantic-search/model';

import Controller from './explorer-event-controller';

Expand All @@ -16,6 +18,12 @@ function obtainer<T extends readonly string[]>(namespace: Namespace<T>) {
export const getSource = obtainer(source);
export const getItem = obtainer(itemNs);

export function getQuery(serial: string) {
const model = semChannel.request('userQueries').add({ id: serial });
model.fetch();
return model;
}

export function sourceWithoutAnnotations(control: Controller, node: Node) {
return control.resetSource(node, false);
}
Expand Down Expand Up @@ -69,3 +77,9 @@ export function itemWithOccurrences(control: Controller, node: Node) {
export function searchResultsSources(control: Controller, queryParams: any) {
return control.resetSourceListFromSearchResults(queryParams);
}

export
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export is placed wrong/not consistent here. Minor.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JavaScript/TypeScript allows it and it keeps the lines within 80 columns. It seemed less disruptive than using Egyptian parens for the parameter list. Please feel free to format differently when you see code like this (I've done it in more places).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No problem. My IDE didn't format it as a function, but the code ran without a problem, so its the language server that's wrong :)

function searchResultsSemantic(control: Controller, model: SemanticQuery) {
model.when('query', () => semChannel.trigger('presentQuery', model));
control.resetSemanticSearch(model);
}
2 changes: 1 addition & 1 deletion frontend/src/explorer/route-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ export default {
'item:external:edit': 'explore/item/:serial/external/edit',
'item:annotations': 'explore/item/:serial/annotations',
'search:results:sources': 'explore/sources?*queryParams',
'search:results:semantic': 'explore/query',
'search:results:semantic': 'explore/query/:serial',
};
6 changes: 6 additions & 0 deletions frontend/src/explorer/utilities-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ describe('explorer utilities', function() {
expectRoute.call(this, handler, 'explore/item/20');
});

it('permits plain serial numbers', function() {
this.mockPanel.model.id = 20;
const handler = announceRoute('item', ['model', 'id']);
expectRoute.call(this, handler, 'explore/item/20');
});

it('permits plain routes', function() {
const handler = announceRoute('explore');
expectRoute.call(this, handler, 'explore');
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/explorer/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export function announceRoute(route: string, path?: string[]) {
* @fires Events#currentRoute
*/
return function(): void {
const serial = getLabelFromId(get(this, path, ''));
const pathResult = get(this, path, '');
const serial = getLabelFromId(pathResult) || pathResult;
const route = pattern.replace(':serial', serial);

/**
Expand Down
Loading