diff --git a/django/researchdata/admin.py b/django/researchdata/admin.py index db7bacd..6fbbed3 100644 --- a/django/researchdata/admin.py +++ b/django/researchdata/admin.py @@ -108,7 +108,7 @@ class PromptAdminView(GenericAdminView): @admin.register(models.Response) class ResponseAdminView(GenericAdminView): """ - Customise the content of the list of Prompts in the Django admin + Customise the content of the list of Responses in the Django admin """ list_display = ('id', 'response_content', @@ -118,3 +118,22 @@ class ResponseAdminView(GenericAdminView): 'meta_lastupdated_datetime') list_filter = ('admin_approved',) actions = (approve, unapprove) + + +@admin.register(models.NotRelevantReport) +class NotRelevantReportAdminView(GenericAdminView): + """ + Customise the content of the list of NotRelevantReports in the Django admin + """ + list_display = ('id', + 'prompt', + 'user_search_query', + 'meta_created_datetime') + + +@admin.register(models.DataInsert) +class DataInsertAdminView(GenericAdminView): + """ + Customise the content of the list of DataInserts in the Django admin + """ + list_display = ('id', 'meta_created_datetime') diff --git a/django/researchdata/migrations/0002_datainsert_notrelevantreport.py b/django/researchdata/migrations/0002_datainsert_notrelevantreport.py new file mode 100644 index 0000000..edeb4e9 --- /dev/null +++ b/django/researchdata/migrations/0002_datainsert_notrelevantreport.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.17 on 2024-12-24 21:53 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('researchdata', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DataInsert', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('create_triggers', models.TextField(blank=True, help_text='Include a comma separated list of new Triggers to create them, e.g. "apple, banana, pear"', null=True)), + ('meta_created_datetime', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ], + ), + migrations.CreateModel( + name='NotRelevantReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_search_query', models.TextField()), + ('admin_notes', models.TextField(blank=True, null=True)), + ('meta_created_datetime', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('prompt', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='notrelevantreports', to='researchdata.prompt')), + ], + options={ + 'ordering': ['-meta_created_datetime'], + }, + ), + ] diff --git a/django/researchdata/models.py b/django/researchdata/models.py index c841f14..6a5373b 100644 --- a/django/researchdata/models.py +++ b/django/researchdata/models.py @@ -136,3 +136,47 @@ def __str__(self): class Meta: ordering = ['-meta_created_datetime'] + + +class NotRelevantReport(models.Model): + """ + The report that a user submits if information is not relevant to their search + """ + + prompt = models.ForeignKey(Prompt, related_name='notrelevantreports', on_delete=models.PROTECT) + user_search_query = models.TextField() + + admin_notes = models.TextField(blank=True, null=True) + + meta_created_datetime = models.DateTimeField(auto_now_add=True, verbose_name="created") + + def __str__(self): + return f'Response #{self.id} - {str(self.meta_created_datetime)[:19]}: {textwrap.shorten(self.response_content, width=50, placeholder="...")}' + + class Meta: + ordering = ['-meta_created_datetime'] + + +class DataInsert(models.Model): + """ + A model that allows data to be easily inserted into other models in this project + """ + + create_triggers = models.TextField( + blank=True, null=True, + help_text='Include a comma separated list of new Triggers to create them, e.g. "apple, banana, pear"' + ) + meta_created_datetime = models.DateTimeField(auto_now_add=True, verbose_name="created") + + def save(self, *args, **kwargs): + """ + Create objects for each specified field. + """ + # Triggers + if self.create_triggers: + for trigger in self.create_triggers.split(','): + t = trigger.strip() + if len(t): + Trigger.objects.get_or_create(trigger_text=t) + # Save this DataInsert object + super().save(*args, **kwargs) diff --git a/django/researchdata/urls.py b/django/researchdata/urls.py index cf44880..175c36f 100644 --- a/django/researchdata/urls.py +++ b/django/researchdata/urls.py @@ -7,4 +7,5 @@ urlpatterns = [ path('prompt/get/', views.prompt_get, name='prompt-get'), path('response/post/', views.response_post, name='response-post'), + path('notrelevantreport/post/', views.notrelevantreport_post, name='notrelevantreport-post'), ] diff --git a/django/researchdata/views.py b/django/researchdata/views.py index ed14d89..b25f6a7 100644 --- a/django/researchdata/views.py +++ b/django/researchdata/views.py @@ -1,4 +1,5 @@ from django.http import JsonResponse +from django.forms.models import model_to_dict from django.views.decorators.csrf import csrf_exempt from . import models @@ -9,21 +10,48 @@ def prompt_get(request): """ user_search_query = request.GET.get('user_search_query', '') + search_exact = int(request.GET.get('search_exact', '0')) + topics_exclude_get = request.GET.get('topics_exclude', '') + topics_exclude = [] + if len(topics_exclude_get): + for topic in topics_exclude_get.split(','): + if topic: + topics_exclude.append(int(topic)) if len(user_search_query): - # Try to find a match for each word in user search query - # E.g. if user searches "the ukraine war" then match "ukraine" and return prompt data - # If multiple found, return the highest priority prompt - for word in user_search_query.split(' '): - prompt = models.Prompt.objects.filter(triggers__trigger_text__icontains=word).order_by('-priority').first() + # If user has requested an exact search, include the full search term + # Otherwise, do a search for each individual word in the search query + search_terms = [user_search_query] if search_exact == 1 else user_search_query.split(' ') + for search_term in search_terms: + # Clean search term, if user hasn't asked for an exact search + # e.g. if user searches "walking" or "walks" remove the extra chars to just get "walk" + if search_exact == 0: + # Remove 's' from end, as this is likely plural + if len(search_term) > 2 and search_term.endswith('s'): + search_term = search_term[:-1] + # Remove 'ing' and 'ies' from end + elif len(search_term) > 4 and search_term.endswith('ing') or search_term.endswith('ies'): + search_term = search_term[:-3] + # Find a prompt that matches the search term + prompts = models.Prompt.objects.all() + if len(topics_exclude): + prompts = prompts.exclude(id__in=topics_exclude) + prompt = prompts.filter(triggers__trigger_text__icontains=search_term).order_by('-priority').first() + # Build the response data dict, with a list of available topics + response_data = { + 'topics': [ + {**model_to_dict(topic), **{'excluded': 1 if topic.id in topics_exclude else 0}} + for topic in models.TopicGroup.objects.all() + ] + } + # If a prompt is found, add it to the response dict if prompt: - return JsonResponse({ - 'prompt': { - 'id': prompt.id, - 'topic': str(prompt.topic), - 'prompt_content': prompt.prompt_content.replace('\n', '
'), - 'response_required': prompt.response_required - } - }) + response_data['prompt'] = { + 'id': prompt.id, + 'topic': str(prompt.topic), + 'prompt_content': prompt.prompt_content.replace('\n', '
'), + 'response_required': prompt.response_required + } + return JsonResponse(response_data) # If a matching prompt can't be found then return a False prompt to client return JsonResponse({'prompt': False}) @@ -37,14 +65,29 @@ def response_post(request): user_response_content = request.POST.get('user_response_content', '') active_prompt_id = request.POST.get('active_prompt_id', '') - response = None - if len(user_response_content) and len(active_prompt_id): response = models.Response.objects.create( prompt=models.Prompt.objects.get(id=active_prompt_id), response_content=user_response_content ) - data = {'response_saved': 1 if response else 0} return JsonResponse(data) + + +@csrf_exempt +def notrelevantreport_post(request): + """ + Function-based view to create a new NotRelevantReport data object + """ + + active_prompt_id = request.POST.get('active_prompt_id', '') + user_search_query = request.POST.get('user_search_query', '') + report = None + if len(user_search_query) and len(active_prompt_id): + report = models.NotRelevantReport.objects.create( + prompt=models.Prompt.objects.get(id=active_prompt_id), + user_search_query=user_search_query + ) + data = {'report_saved': 1 if report else 0} + return JsonResponse(data) diff --git a/web_extension_chrome/local_settings.example.js b/web_extension_chrome/local_settings.example.js index 8dead8c..4db6ed6 100644 --- a/web_extension_chrome/local_settings.example.js +++ b/web_extension_chrome/local_settings.example.js @@ -12,4 +12,5 @@ Don't change the const names as these are used elsewhere in the extension. // Provide the URLs for the API request (used in popup.js to get/post data from/to the API) const apiUrl = 'http://localhost:8001'; const apiUrlPromptGet = `${apiUrl}/data/prompt/get/`; -const apiUrlPromptPost = `${apiUrl}/data/response/post/`; +const apiUrlResponsePost = `${apiUrl}/data/response/post/`; +const apiUrlNotRelevantReportPost = `${apiUrl}/data/notrelevantreport/post/`; diff --git a/web_extension_chrome/manifest.json b/web_extension_chrome/manifest.json index c5b2b05..2f81458 100644 --- a/web_extension_chrome/manifest.json +++ b/web_extension_chrome/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Ethical Interface", - "version": "1.0.0", + "version": "1.1.0", "description": "Engage with the Ethical Interface research project run by Rosie Graham at the University of Birmingham", "permissions": ["tabs"], "host_permissions": [""], diff --git a/web_extension_chrome/popup.css b/web_extension_chrome/popup.css index ae66d9d..8396ddb 100644 --- a/web_extension_chrome/popup.css +++ b/web_extension_chrome/popup.css @@ -89,6 +89,26 @@ strong { text-align: center; } +#notrelevanttoggle { + width: fit-content; + margin: 3em auto 1em auto; + color: #AAA; + text-decoration: underline; + cursor: pointer; +} + +#notrelevant { + display: none; + background: rgba(255, 255, 255, 0.1); + padding: 1em; + margin: 1em 0; +} + +#notrelevant span { + text-decoration: underline; + cursor: pointer; +} + #popup-footer { border-top: 0.2em solid white; padding-top: 0.5em; diff --git a/web_extension_chrome/popup.js b/web_extension_chrome/popup.js index 3d7c222..cd33530 100644 --- a/web_extension_chrome/popup.js +++ b/web_extension_chrome/popup.js @@ -8,80 +8,153 @@ $(document).ready(function(){ const container = $('#popup-main'); let activePromptId; + let userSearchQuery; + let searchExact = 0; + let topicsExclude = ''; // async: Load the main content of the popup by using the user's search query to interact with the web API - (async () => { - const activeTab = await getActiveTab(); - const queryParameters = activeTab.url.split('?')[1]; - const urlParameters = new URLSearchParams(queryParameters); - const userSearchQuery = urlParameters.get('q'); + function loadMainContent(){ + (async () => { + const activeTab = await getActiveTab(); + const queryParameters = activeTab.url.split('?')[1]; + const urlParameters = new URLSearchParams(queryParameters); + userSearchQuery = urlParameters.get('q'); - // If on a valid Google search results page - if (activeTab.url.includes('google.com/search') && userSearchQuery) { - container.html(`
You searched for:
${userSearchQuery}
`); + // If on a valid Google search results page + if (activeTab.url.includes('google.com/search') && userSearchQuery) { + container.html(`
You searched for:
${userSearchQuery}
`); - // Get the prompt via AJAX based on user's search query - $.get(`${apiUrlPromptGet}?user_search_query=${userSearchQuery}`, function(data){ - // Define prompt object - let prompt = data.prompt; - // If a valid prompt was found then display it - if (prompt){ - activePromptId = prompt.id; - // Build HTML - let htmlToInject = ` -
-
- Your search appears to be related to a research topic that we're interested in: - ${prompt.topic} -
-
- ${prompt.prompt_content} -
-
`; - // Add response - if (prompt.response_required){ + // Get the prompt via AJAX based on user's search query + $.get(`${apiUrlPromptGet}?user_search_query=${userSearchQuery}&search_exact=${searchExact}&topics_exclude=${topicsExclude}`, function(data){ + let prompt = data.prompt; + let htmlToInject = ''; + // If a valid prompt was found then display it + if (prompt){ + activePromptId = prompt.id; + // Build HTML htmlToInject += ` -
- - +
+
+ Your search appears to be related to a research topic that we're interested in: + ${prompt.topic} +
+
+ ${prompt.prompt_content} +
`; + // Add response + if (prompt.response_required){ + htmlToInject += ` +
+ + +
`; + } + } + // If a valid prompt was NOT found then show helpful message to user + else { + htmlToInject += ` +

Your search doesn't appear to be related to our current research interests.

+

Please try adjusting your search or contact us for further support.

+ `; } + // Add content for allowing user to take action if search isn't relevant + // User can try again by doing an exact search or create a report + htmlToInject += ` +
Result not relevant?
+
+

+ If this information shown above isn't relevant to your search, please try reloading with an exact search match. +

+

+ You can also try limiting which topics are included. Untick a topic and click Reload to try again without these topics:`; + for (var i = 0; i < data.topics.length; i++){ + let topic = data.topics[i]; + let checked = ' checked'; + if (topic['excluded'] == 1) checked = '' + htmlToInject += `

+ + +
`; + } + htmlToInject += ` + +

+

+ If the information shown still isn't relevant to your search, please report this to us so that we can work to improve the accuracy of this tool. +

+
`; // Inject HTML container.append(htmlToInject); - } - // If a valid prompt was NOT found then show helpful message to user - else { + }).fail(function(){ + // If the get request fails, display an appropriate message to user container.append(` -

Your search doesn't appear to be related to our current research interests.

-

Please try adjusting your search or contact us for further support.

+

There's been an error when trying to connect to the server.

+

Please try again or contact us for support if the problem persists.

`); - } - }).fail(function(){ - // If the get request fails, display an appropriate message to user - container.append(` -

There's been an error when trying to connect to the server.

-

Please try again or contact us for support if the problem persists.

- `); - }); + }); - } - // If not on a valid Google search results page - else { - container.html(` -

You're not currently on a Google search results page.

-

To use this extension please perform a Google search and then load this extension again.

` - ); - } - })(); + } + // If not on a valid Google search results page + else { + container.html(` +

You're not currently on a Google search results page.

+

To use this extension please perform a Google search and then load this extension again.

` + ); + } + })(); + } + // Load content on popup start + loadMainContent(); + + // User clicks "not relevant" exact search button: + // Reloads search using 'exact search' that will only match the entire search term, not words within it + $('body').on('click', '#notrelevanttoggle', function(){ + $('#notrelevant').toggle(); + }); + + // User clicks "not relevant" exact search button: + // Reloads search using 'exact search' that will only match the entire search term, not words within it + $('body').on('click', '#notrelevant-exactsearch', function(){ + searchExact = 1; + loadMainContent(); + }); + + // User clicks the "not relevant" topic reload button: + // Reloads search and excludes any unticked topics + $('body').on('click', '#notrelevant-topic-reload', function(){ + topicsExclude = $(".notrelevant-topic input:checkbox:not(:checked)").map(function(){return $(this).val();}).get().join(','); + loadMainContent(); + }); + + // User clicks the "not relevant" report post button: + // Sends report to API + $('body').on('click', '#notrelevant-report', function(){ + $.post(apiUrlNotRelevantReportPost, {user_search_query: userSearchQuery, active_prompt_id: activePromptId}, function(data, status){ + if (data.report_saved == 1){ + container.html('
Thank you, your report has been successfully sent to us and will be used to improve the product.
Please contact us if you would like to provide further feedback
'); + } + else { + container.html('
Error: Your report was not sent successfully. Please try again or contact us for further support.'); + } + }).fail(function(){ + // If the post request fails, display an appropriate message to user + container.append(` +

There's been an error when trying to send your report to the server.

+

Please try again or contact us for support if the problem persists.

+ `); + }); + }); - // User clicks the response post button: sends response to API + // User clicks the response post button: + // Sends response to API $('body').on('click', '#response-post-button', function(){ let responseContent = $('#response-content').val(); if (responseContent.length > 0){ // Post the response - $.post(apiUrlPromptPost, {user_response_content: responseContent, active_prompt_id: activePromptId}, function(data, status){ + $.post(apiUrlResponsePost, {user_response_content: responseContent, active_prompt_id: activePromptId}, function(data, status){ if (data.response_saved == 1){ - container.html('
Your response has been successfully posted!
'); + container.html('
Your response has been successfully posted!
'); } else { container.html('
Error: Your post was not saved successfully. Please try again or contact us for further support.'); @@ -96,8 +169,9 @@ $(document).ready(function(){ } }); - // User clicks the finished button: close pop up - $('body').on('click', '#response-post-finished-button', function(){ + // User clicks the finished button: + // Closes pop up + $('body').on('click', '.btn-close', function(){ window.close(); }); }); \ No newline at end of file