diff --git a/.gitignore b/.gitignore index ed90f2f7a841da90717ad986c7bb57dbc86a8876..70d2c1202e645fc31260a0fdd6bac90fdd25e15a 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ docs/_build/ # Generated files aleksis/node_modules/ aleksis/static/ +aleksis/whoosh_index/ +aleksis/xapian_index/ .coverage .mypy_cache/ diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index 8ba220c1610321971f6b7c96ca20b7ce65fe0d5b..574cf185cc2350e3e7b45f7723c8e9313a631790 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -71,6 +71,13 @@ class ExtensibleModel(CRUDMixin): - Dominik George <dominik.george@teckids.org> """ + # Defines a material design icon associated with this type of model + icon_ = "radio_button_unchecked" + + def get_absolute_url(self) -> str: + """ Get the URL o a view representing this model instance """ + pass + @property def crud_event_create(self) -> Optional[CRUDEvent]: """ Return create event of this object """ diff --git a/aleksis/core/models.py b/aleksis/core/models.py index d87465c83b12427b7a969997278df31f3f273268..b9b29ea5f7372e54a626081e3d79e516a9b863d8 100644 --- a/aleksis/core/models.py +++ b/aleksis/core/models.py @@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import QuerySet from django.forms.widgets import Media +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from image_cropping import ImageCropField, ImageRatioField @@ -95,6 +96,8 @@ class Person(ExtensibleModel): verbose_name = _("Person") verbose_name_plural = _("Persons") + icon_ = "person" + SEX_CHOICES = [("f", _("female")), ("m", _("male"))] user = models.OneToOneField( @@ -136,6 +139,10 @@ class Person(ExtensibleModel): description = models.TextField(verbose_name=_("Description"), blank=True, null=True) + + def get_absolute_url(self) -> str: + return reverse("person_by_id", args=[self.id]) + @property def primary_group_short_name(self) -> Optional[str]: """ Returns the short_name field of the primary @@ -242,6 +249,8 @@ class Group(ExtensibleModel): verbose_name = _("Group") verbose_name_plural = _("Groups") + icon_ = "group" + name = models.CharField(verbose_name=_("Long name of group"), max_length=255, unique=True) short_name = models.CharField(verbose_name=_("Short name of group"), max_length=255, unique=True, blank=True, null=True) @@ -256,7 +265,12 @@ class Group(ExtensibleModel): blank=True, ) +<<<<<<< HEAD + def get_absolute_url(self) -> str: + return reverse("group_by_id", args=[self.id]) +======= type = models.ForeignKey("GroupType", on_delete=models.CASCADE, related_name="type", verbose_name=_("Type of group"), null=True, blank=True) +>>>>>>> master @property def announcement_recipients(self): diff --git a/aleksis/core/search_indexes.py b/aleksis/core/search_indexes.py new file mode 100644 index 0000000000000000000000000000000000000000..5828e0c52391423cc6dd0bad43f6a15310a85e1f --- /dev/null +++ b/aleksis/core/search_indexes.py @@ -0,0 +1,10 @@ +from .models import Person, Group +from .util.search import Indexable, SearchIndex + + +class PersonIndex(SearchIndex, Indexable): + model = Person + + +class GroupIndex(SearchIndex, Indexable): + model = Group diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py index e0f652eca2754a12d0e60fc86ce0805071e97507..eb4b3e07401fae2a830c530212747877ced2af93 100644 --- a/aleksis/core/settings.py +++ b/aleksis/core/settings.py @@ -53,6 +53,7 @@ INSTALLED_APPS = [ "django.contrib.sites", "django.contrib.staticfiles", "django.contrib.humanize", + "haystack", "polymorphic", "django_global_request", "dbbackup", @@ -571,3 +572,34 @@ LOGGING = { 'level': _settings.get("logging.level", "WARNING"), }, } + +HAYSTACK_BACKEND_SHORT = _settings.get("search.backend", "simple") + +if HAYSTACK_BACKEND_SHORT == "simple": + HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'haystack.backends.simple_backend.SimpleEngine', + }, + } +elif HAYSTACK_BACKEND_SHORT == "xapian": + HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'xapian_backend.XapianEngine', + 'PATH': _settings.get("search.index", os.path.join(BASE_DIR, "xapian_index")), + }, + } +elif HAYSTACK_BACKEND_SHORT == "whoosh": + HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'haystack.backends.whoosh_backend.WhooshEngine', + 'PATH': _settings.get("search.index", os.path.join(BASE_DIR, "whoosh_index")), + }, + } + +if _settings.get("celery.enabled", False) and _settings.get("search.celery", True): + INSTALLED_APPS.append("celery_haystack") + HAYSTACK_SIGNAL_PROCESSOR = 'celery_haystack.signals.CelerySignalProcessor' +else: + HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' + +HAYSTACK_SEARCH_RESULTS_PER_PAGE = 10 diff --git a/aleksis/core/static/js/main.js b/aleksis/core/static/js/main.js index 27b5c5ad8fb126391f98e61b75883f965e9970e2..403837da22d351d8e97ed3cf6c27c79e30514497 100644 --- a/aleksis/core/static/js/main.js +++ b/aleksis/core/static/js/main.js @@ -65,6 +65,10 @@ $(document).ready( function () { }); }); + // Initialise auto-completion for search bar + window.autocomplete = new Autocomplete({}); + window.autocomplete.setup(); + // Initialize text collapsibles [MAT, own work] $(".text-collapsible").addClass("closed").removeClass("opened"); diff --git a/aleksis/core/static/js/search.js b/aleksis/core/static/js/search.js new file mode 100644 index 0000000000000000000000000000000000000000..7943d2ebfaa07123bf0174b24c055884ceb0bd36 --- /dev/null +++ b/aleksis/core/static/js/search.js @@ -0,0 +1,121 @@ +/* + * Based on: https://django-haystack.readthedocs.io/en/master/autocomplete.html + * + * Š Copyright 2009-2016, Daniel Lindsley + * Licensed under the 3-clause BSD license + */ + +var Autocomplete = function (options) { + this.form_selector = options.form_selector || '.autocomplete'; + this.url = options.url || Urls.searchbarSnippets(); + this.delay = parseInt(options.delay || 300); + this.minimum_length = parseInt(options.minimum_length || 3); + this.form_elem = null; + this.query_box = null; + this.selected_element = null; +}; + +Autocomplete.prototype.setup = function () { + var self = this; + + this.form_elem = $(this.form_selector); + this.query_box = this.form_elem.find('input[name=q]'); + + + $("#search-form").focusout(function (e) { + if (!$(e.relatedTarget).hasClass("search-item")) { + e.preventDefault(); + $("#search-results").remove(); + } + }); + + // Trigger the "keyup" event if input gets focused + + this.query_box.focus(function () { + self.query_box.trigger("keydown"); + }); + + // Watch the input box. + this.query_box.keydown(function (e) { + var query = self.query_box.val(); + + if (e.which === 38) { // Keypress Up + if (!self.selected_element) { + self.setSelectedResult($("#search-collection").children().last()); + return false; + } + + let prev = self.selected_element.prev(); + if (prev.length > 0) { + self.setSelectedResult(prev); + } + return false; + } + + if (e.which === 40) { // Keypress Down + if (!self.selected_element) { + self.setSelectedResult($("#search-collection").children().first()); + return false; + } + + let next = self.selected_element.next(); + if (next.length > 0) { + self.setSelectedResult(next); + } + return false; + } + + if (self.selected_element && e.which === 13) { + e.preventDefault(); + window.location.href = self.selected_element.attr("href"); + } + + if (query.length < self.minimum_length) { + $("#search-results").remove(); + return true; + } + + self.fetch(query); + return true; + }); + + // // On selecting a result, remove result box + // this.form_elem.on('click', '#search-results', function (ev) { + // $('#search-results').remove(); + // return true; + // }); + + // Disable browser's own autocomplete + // We do this here so users without JavaScript can keep it enabled + this.query_box.attr('autocomplete', 'off'); +}; + +Autocomplete.prototype.fetch = function (query) { + var self = this; + + $.ajax({ + url: this.url + , data: { + 'q': query + } + , success: function (data) { + self.show_results(data); + } + }) +}; + +Autocomplete.prototype.show_results = function (data) { + $('#search-results').remove(); + var results_wrapper = $('<div id="search-results">' + data + '</div>'); + this.query_box.after(results_wrapper); + this.selected_element = null; +}; + +Autocomplete.prototype.setSelectedResult = function (element) { + if (this.selected_element) { + this.selected_element.removeClass("active"); + } + element.addClass("active"); + this.selected_element = element; + console.log("New element: ", element); +}; diff --git a/aleksis/core/static/style.scss b/aleksis/core/static/style.scss index e2cc69479f8cca34d7f2b95c2b70d61aba284174..e1edc75561fb4fe57568f1b148b8ab0f955c1b58 100644 --- a/aleksis/core/static/style.scss +++ b/aleksis/core/static/style.scss @@ -8,6 +8,14 @@ color: $primary-color !important; } +.secondary-color { + background-color: $secondary-color !important; +} + +.secondary-color-text, .secondary-color-text a { + color: $secondary-color !important; +} + rect#background { fill: $primary-color !important; } @@ -72,7 +80,7 @@ header, main, footer { #sidenav-logo { height: 70px; - width:auto; + width: auto; } @media only screen and (max-width: 993px) { @@ -129,6 +137,66 @@ li.active > a > .sidenav-badge { color: $primary-color !important; } +.sidenav li.search { + position: relative; + z-index: 2; +} + +.sidenav li.search:hover { + background-color: #fff; +} + +.sidenav li.search .search-wrapper { + color: #777; + margin-top: -1px; + border-top: 1px solid rgba(0, 0, 0, 0.14); + border-bottom: 1px solid rgba(0, 0, 0, 0.14); + + -webkit-transition: margin .25s ease; + transition: margin .25s ease; +} + +.sidenav li.search .search-wrapper input#search { + color: #777; + display: block; + font-size: 16px; + font-weight: 300; + width: 100%; + height: 62px; + margin: 0; + -webkit-box-sizing: border-box; + box-sizing: border-box; + padding: 0 45px 0 30px; + border: 0; +} + +.sidenav li.search .search-wrapper input#search:focus { + outline: none; + -webkit-box-shadow: none; + box-shadow: none; +} + +.sidenav li.search .search-wrapper > i.material-icons { + position: absolute; + top: 21px; + right: 10px; + cursor: pointer; +} + +a.collection-item.search-item { + padding: 20px 10px; +} + +div#search-results { + position: absolute; + width: 100%; +} + +.search-result-icon { + position: absolute; + right: 10px; +} + // Sidenav trigger @@ -204,6 +272,43 @@ form .row { margin-bottom: 0; } +label.chips-checkbox { + &.active { + outline: none; + background-color: $chip-selected-color; + color: #fff; + } + + display: inline-block; + height: 32px; + font-size: 13px; + font-weight: 500; + color: rgba(0, 0, 0, .6); + line-height: 32px; + padding: 0 12px; + border-radius: 16px; + background-color: $chip-bg-color; + margin-bottom: $chip-margin; + margin-right: $chip-margin; + + > img { + float: left; + margin: 0 8px 0 -12px; + height: 32px; + width: 32px; + border-radius: 50%; + } +} + +input[type="checkbox"].chips-checkbox + span { + padding-left: 0; + + &:before { + display: none; + width: 0; + } +} + // Badges span.badge.new::after { @@ -261,7 +366,7 @@ span.badge .material-icons { /* Table*/ -table.striped > tbody > tr:nth-child(odd), table tr.striped, table tbody.striped tr { +table.striped > tbody > tr:nth-child(odd), table tr.striped, table tbody.striped tr { background-color: rgba(208, 208, 208, 0.5); } @@ -507,4 +612,3 @@ main .alert p:first-child, main .alert div:first-child { overflow: visible; width: 100%; } - diff --git a/aleksis/core/static/theme.scss b/aleksis/core/static/theme.scss index 36d3e3f55a0e37595f3dcc27230947875cd2648b..907c444f5bf99ef3616ae47a4c6df8b2e82b011e 100644 --- a/aleksis/core/static/theme.scss +++ b/aleksis/core/static/theme.scss @@ -127,7 +127,7 @@ $collapsible-border-color: #ddd !default; $chip-bg-color: #e4e4e4 !default; $chip-border-color: #9e9e9e !default; -$chip-selected-color: #26a69a !default; +$chip-selected-color: $primary-color !default; $chip-margin: 5px !default; diff --git a/aleksis/core/templates/core/base.html b/aleksis/core/templates/core/base.html index b75eb9ec30f7ac9a29be5bd11a72921ca2d08ad8..23bfdf57d69cfbf1b11347bf04af1c75f80f2f23 100644 --- a/aleksis/core/templates/core/base.html +++ b/aleksis/core/templates/core/base.html @@ -32,6 +32,8 @@ {# Include jQuery to provide $(document).ready #} {% include_js "jQuery" %} + <script type="text/javascript" src="{% static 'js/search.js' %}"></script> + {% block extra_head %}{% endblock %} </head> <body> @@ -69,6 +71,14 @@ </object> </a> </li> + <li class="search"> + <form method="get" action="{% url "haystack_search" %}" id="search-form" class="autocomplete"> + <div class="search-wrapper"> + <input id="search" name="q" placeholder="{% trans "Search" %}"> + <i class="material-icons">search</i> + </div> + </form> + </li> <li class="no-padding"> {% include "core/sidenav.html" %} </li> diff --git a/aleksis/core/templates/search/indexes/core/group_text.txt b/aleksis/core/templates/search/indexes/core/group_text.txt new file mode 100644 index 0000000000000000000000000000000000000000..165c30e8c240ddecc872520626cccb598a6ad7a0 --- /dev/null +++ b/aleksis/core/templates/search/indexes/core/group_text.txt @@ -0,0 +1,2 @@ +{{ object.name }} +{{ object.short_name }} diff --git a/aleksis/core/templates/search/indexes/core/person_text.txt b/aleksis/core/templates/search/indexes/core/person_text.txt new file mode 100644 index 0000000000000000000000000000000000000000..210e1755e071a6ced9807790f54403ee5bc85f19 --- /dev/null +++ b/aleksis/core/templates/search/indexes/core/person_text.txt @@ -0,0 +1,3 @@ +{{ object.full_name }} +{{ object.user.username }} +{{ object.email }} diff --git a/aleksis/core/templates/search/search.html b/aleksis/core/templates/search/search.html new file mode 100644 index 0000000000000000000000000000000000000000..babbd70d945f26cbf777ae46ca0add15a24e442d --- /dev/null +++ b/aleksis/core/templates/search/search.html @@ -0,0 +1,108 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} + +{% load i18n material_form_internal %} + +{% block browser_title %}{% blocktrans %}Search{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Global Search{% endblocktrans %}{% endblock %} + +{% block content %} + <form method="get"> + {# {% form form=form %}{% endform %}#} + + <input type="text" name="{{ form.q.name }}" id="{{ form.q.id }}" value="{% firstof form.q.value "" %}" + placeholder="{% trans "Search Term" %}"> + + <h6>{{ form.models.label }}</h6> + <div> + {% for group, items in form.models|select_options %} + {% for choice, value, selected in items %} + <label class="{% if selected %} active{% endif %}"> + <input type="checkbox" + {% if value == None or value == '' %}disabled{% else %}value="{{ value }}"{% endif %} + {% if selected %} checked="checked"{% endif %} name="{{ form.models.name }}"> + <span> {{ choice }} </span> + </label> + {% endfor %} + {% endfor %} + </div> + + <button type="submit" class="btn waves-effect waves-light green"> + <i class="material-icons left">search</i> + {% blocktrans %}Search{% endblocktrans %} + </button> + + <h5>{% trans "Results" %}</h5> + + {% if query %} + <div class="collection"> + {% for result in page.object_list %} + <a href="{{ result.object.get_absolute_url|default:"#" }}" class="collection-item"> + <i class="material-icons left">{{ result.object.icon_ }}</i> + {{ result.object }} + </a> + {% empty %} + <li class="collection-item"> + {% trans "No search results could be found to your search" %} + </li> + {% endfor %} + </div> + + {% if page.has_other_pages %} + <ul class="pagination"> + {% if page.has_previous %} + <li class="waves-effect"> + <a href="?q={{ query }}&page={{ page.previous_page_number }}"> + <i class="material-icons">chevron_left</i> + </a> + </li> + {% else %} + <li class="disabled"><a href="#"><i class="material-icons">chevron_left</i></a></li> + {% endif %} + + {% for page_num in page.paginator.page_range %} + {% if page.number == page_num %} + <li class="active"><a href="#">{{ page_num }}</a></li> + {% else %} + <li class="waves-effect"><a href="?q={{ query }}&page={{ page_num }}">{{ page_num }}</a></li> + {% endif %} + {% endfor %} + + {% if page.has_next %} + <li class="waves-effect"> + <a href="?q={{ query }}&page={{ page.next_page_number }}"> + <i class="material-icons">chevron_right</i> + </a> + </li> + {% else %} + <li class="disabled"><a href="#"><i class="material-icons">chevron_right</i></a></li> + {% endif %} + </ul> + {% endif %} + {% else %} + <div class="collection"> + <li class="collection-item"> + {% trans "Please enter a search term above" %} + </li> + </div> + {% endif %} + + + </form> + + <script> + $(document).ready(function () { + $("input[type='checkbox']").each(function () { + $(this).addClass("chips-checkbox"); + $(this).parent("label").addClass("chips-checkbox"); + }); + + $("label.chips-checkbox > span").click(function () { + $(this).parent("label.chips-checkbox").toggleClass("active"); + let input = $(this).next("input[type='checkbox']"); + input.prop("checked", !input.prop("checked")); + }); + }); + </script> +{% endblock %} diff --git a/aleksis/core/templates/search/searchbar_snippet.html b/aleksis/core/templates/search/searchbar_snippet.html new file mode 100644 index 0000000000000000000000000000000000000000..b5b7188c6a1e0381fd30f41b6fccc7e587949a30 --- /dev/null +++ b/aleksis/core/templates/search/searchbar_snippet.html @@ -0,0 +1,4 @@ +<a href="{{ result.object.get_absolute_url|default:"#" }}" class="collection-item search-item"> + {{ result.object }} + <i class="material-icons secondary-content search-result-icon">{{ result.object.icon_ }}</i> +</a> diff --git a/aleksis/core/templates/search/searchbar_snippets.html b/aleksis/core/templates/search/searchbar_snippets.html new file mode 100644 index 0000000000000000000000000000000000000000..373c93a7ec5d9311167abab0655d3444b3e57e73 --- /dev/null +++ b/aleksis/core/templates/search/searchbar_snippets.html @@ -0,0 +1,5 @@ +<div class="collection" id="search-collection"> + {% for result in results %} + {% include "search/searchbar_snippet.html" %} + {% endfor %} +</div> diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index e5320dd02f05a884a8891c7793305f8c0672d7fc..4bb7d10fbbaa6774d5f5beed3a1d7e034f020260 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -39,6 +39,8 @@ urlpatterns = [ path("announcement/create/", views.announcement_form, name="add_announcement"), path("announcement/edit/<int:pk>/", views.announcement_form, name="edit_announcement"), path("announcement/delete/<int:pk>/", views.delete_announcement, name="delete_announcement"), + path("search/searchbar/", views.searchbar_snippets, name="searchbar_snippets"), + path("search/", include("haystack.urls")), path("maintenance-mode/", include("maintenance_mode.urls")), path("impersonate/", include("impersonate.urls")), path("__i18n__/", include("django.conf.urls.i18n")), diff --git a/aleksis/core/util/search.py b/aleksis/core/util/search.py new file mode 100644 index 0000000000000000000000000000000000000000..6720fb6b4236f10d3bfb1932969483d4d4db6d8f --- /dev/null +++ b/aleksis/core/util/search.py @@ -0,0 +1,23 @@ +from django.conf import settings + +from haystack import indexes + +# Not used here, but simplifies imports for apps +Indexable = indexes.Indexable # noqa + +if settings.HAYSTACK_SIGNAL_PROCESSOR == 'celery_haystack.signals.CelerySignalProcessor': + from haystack.indexes import SearchIndex as BaseSearchIndex +else: + from celery_haystack.indexes import CelerySearchIndex as BaseSearchIndex + +class SearchIndex(BaseSearchIndex): + """ Base class for search indexes on AlekSIS models + + It provides a default document field caleld text and exects + the related model in the model attribute. + """ + + text = indexes.EdgeNgramField(document=True, use_template=True) + + def get_model(self): + return self.model diff --git a/aleksis/core/views.py b/aleksis/core/views.py index 8a81d701a2f38114b78353eae2a5f4106b06c91d..d3ead324d90b313ffeec3a5068df24c90bc379f4 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -7,6 +7,8 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext_lazy as _ from django_tables2 import RequestConfig +from haystack.inputs import AutoQuery +from haystack.query import SearchQuerySet from .decorators import admin_required, person_required from .forms import ( @@ -323,3 +325,14 @@ def delete_announcement(request: HttpRequest, pk: int) -> HttpResponse: messages.success(request, _("The announcement has been deleted.")) return redirect("announcements") + + +@login_required +def searchbar_snippets(request: HttpRequest) -> HttpResponse: + query = request.GET.get('q', '') + limit = int(request.GET.get('limit', '5')) + + results = SearchQuerySet().filter(text=AutoQuery(query))[:limit] + context = {"results": results} + + return render(request, "search/searchbar_snippets.html", context) diff --git a/pyproject.toml b/pyproject.toml index 5ce4997beed06321f10b5f92dd1d42e32a7e43bc..02448d52ad00c58009c4ba7cb4244f2a3c7da808 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,11 +68,13 @@ django-otp = "0.7.5" django-colorfield = "^0.2.1" django-bleach = "^0.6.1" django-memoize = "^2.2.1" +django-haystack = "^3.0" +celery-haystack = {version="^0.3.1", optional=true} django-dbbackup = "^3.3.0" [tool.poetry.extras] ldap = ["django-auth-ldap"] -celery = ["Celery", "django-celery-results", "django-celery-beat", "django-celery-email"] +celery = ["Celery", "django-celery-results", "django-celery-beat", "django-celery-email", "celery-haystack"] [tool.poetry.dev-dependencies] sphinx = "^2.1"