diff --git a/_1327/documents/utils.py b/_1327/documents/utils.py index 07e029fe..6dc6f589 100644 --- a/_1327/documents/utils.py +++ b/_1327/documents/utils.py @@ -4,10 +4,14 @@ from django.conf import settings +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import SuspiciousOperation from django.db import transaction +from django.shortcuts import Http404 from django.utils import timezone +from guardian.core import ObjectPermissionChecker from reversion import revisions from reversion.models import Version @@ -174,3 +178,40 @@ def delete_cascade_to_json(cascade): "name": str(cascade_item), }) return items + + +def get_permitted_documents(documents, request, groupid): + groupid = int(groupid) + try: + group = Group.objects.get(id=groupid) + except ObjectDoesNotExist: + raise Http404 + + own_group = request.user.is_superuser or group in request.user.groups.all() + + # Prefetch group permissions + group_checker = ObjectPermissionChecker(group) + group_checker.prefetch_perms(documents) + + # Prefetch user permissions + user_checker = ObjectPermissionChecker(request.user) + user_checker.prefetch_perms(documents) + + # Prefetch ip-range group permissions + ip_range_group_name = getattr(request.user, '_ip_range_group_name', None) + if ip_range_group_name: + ip_range_group = Group.objects.get(name=ip_range_group_name) + ip_range_group_checker = ObjectPermissionChecker(ip_range_group) + + permitted_documents = [] + for document in documents: + # we show all documents for which the requested group has edit permissions + # e.g. if you request FSR documents, all documents for which the FSR group has edit rights will be shown + if not group_checker.has_perm(document.edit_permission_name, document): + continue + # we only show documents for which the user has view permissions + if not user_checker.has_perm(Document.get_view_permission(), document) and (not ip_range_group_name or not ip_range_group_checker.has_perm(Document.get_view_permission(), document)): + continue + permitted_documents.append(document) + + return permitted_documents, own_group diff --git a/_1327/information_pages/migrations/0004_auto_20180813_2050_squashed_0005_auto_20180813_2102.py b/_1327/information_pages/migrations/0004_auto_20180813_2050_squashed_0005_auto_20180813_2102.py new file mode 100644 index 00000000..8e98c443 --- /dev/null +++ b/_1327/information_pages/migrations/0004_auto_20180813_2050_squashed_0005_auto_20180813_2102.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-08-13 19:06 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + replaces = [('information_pages', '0004_auto_20180813_2050'), ('information_pages', '0005_auto_20180813_2102')] + + dependencies = [ + ('information_pages', '0003_auto_20180201_2301'), + ] + + operations = [ + migrations.AlterModelOptions( + name='informationdocument', + options={'base_manager_name': 'objects', 'permissions': (('view_informationdocument', 'User/Group is allowed to view that document'),), 'verbose_name': 'Information document', 'verbose_name_plural': 'Information documents'}, + ), + ] diff --git a/_1327/main/templates/menu_item_edit.html b/_1327/main/templates/menu_item_edit.html index b528f610..d767bbc5 100644 --- a/_1327/main/templates/menu_item_edit.html +++ b/_1327/main/templates/menu_item_edit.html @@ -13,7 +13,7 @@ {% endblock %} {% block content %} -
+ {% bootstrap_form form layout='horizontal' %} {% csrf_token %} {% if formset %} diff --git a/_1327/main/templates/searched_list.html b/_1327/main/templates/searched_list.html new file mode 100644 index 00000000..50c04ff0 --- /dev/null +++ b/_1327/main/templates/searched_list.html @@ -0,0 +1,55 @@ +{% extends 'base_with_sidebar.html' %} +{% load i18n %} + +{% block title %} + {% blocktrans count counter=2 %}Documents{% plural %}Documents{% endblocktrans %} +{% endblock %} + +{% block sidebar %} +
+ +
+{% endblock %} + +{% block content %} + {% for type, documents in searched_documents %} +

{{ type }}

+ + {% for document, lines in documents %} + + + + + {% endfor %} +
+ {{ document.title}} {% if document.date %}({{ document.date| date:"d.m.Y" }}){% endif %} +
    + {% for line in lines %} +
  • {{ line }}
  • + {% endfor %} +
+
+ {% if document.attachments.count > 0 %} + + + + {% endif %} +
+ {% empty %} + {% block searched_documentsempty %} + + {% blocktrans %}No documents containing "{{ phrase }}" found.{% endblocktrans %} + + {% url "login" as anchor_url %} + {% if not user.is_authenticated %} + + {% blocktrans with anchor=''|safe anchor_end=''|safe %}You might have to {{ anchor }} login {{ anchor_end }} first.{% endblocktrans %} + + {% endif %} + {% endblock %} + {% endfor %} +{% endblock %} diff --git a/_1327/main/tests.py b/_1327/main/tests.py index 9643d0ea..dc24b289 100644 --- a/_1327/main/tests.py +++ b/_1327/main/tests.py @@ -16,6 +16,7 @@ from _1327.information_pages.models import InformationDocument from _1327.main.utils import find_root_menu_items from _1327.minutes.models import MinutesDocument +from _1327.polls.models import Poll from _1327.user_management.models import UserProfile from .context_processors import mark_selected from .models import MenuItem @@ -105,7 +106,7 @@ def test_create_menu_item_as_superuser_no_document_and_link(self): self.assertEqual(response.status_code, 200) self.assertIn("Link", response.body.decode('utf-8')) - form = response.form + form = response.forms['menu_item_edit'] form['group'].select(text=self.staff_group.name) response = form.submit() @@ -118,7 +119,7 @@ def test_create_menu_item_as_superuser_document_and_link(self): document = mommy.make(InformationDocument) response = self.app.get(reverse('menu_item_create'), user=self.root_user) - form = response.form + form = response.forms['menu_item_edit'] form['link'] = 'polls:index' form['document'].select(text=document.title) form['group'].select(text=self.staff_group.name) @@ -132,7 +133,7 @@ def test_create_menu_item_as_superuser_with_link(self): menu_item_count = MenuItem.objects.count() response = self.app.get(reverse('menu_item_create'), user=self.root_user) - form = response.form + form = response.forms['menu_item_edit'] form['title'] = 'test title' form['link'] = 'polls:index' form['group'].select(text=self.staff_group.name) @@ -146,7 +147,7 @@ def test_create_menu_item_as_superuser_with_link_and_param(self): menu_item_count = MenuItem.objects.count() response = self.app.get(reverse('menu_item_create'), user=self.root_user) - form = response.form + form = response.forms['menu_item_edit'] form['title'] = 'test title' form['link'] = 'minutes:list?groupid={}'.format(self.staff_group.id) form['group'].select(text=self.staff_group.name) @@ -160,7 +161,7 @@ def test_create_menu_item_as_superuser_wrong_link(self): menu_item_count = MenuItem.objects.count() response = self.app.get(reverse('menu_item_create'), user=self.root_user) - form = response.form + form = response.forms['menu_item_edit'] form['title'] = 'test title' form['link'] = 'polls:index?kekse?kekse2' form['group'].select(text=self.staff_group.name) @@ -174,7 +175,7 @@ def test_create_menu_item_as_superuser_wrong_link_2(self): menu_item_count = MenuItem.objects.count() response = self.app.get(reverse('menu_item_create'), user=self.root_user) - form = response.form + form = response.forms['menu_item_edit'] form['title'] = 'test title' form['link'] = 'www.example.com' form['group'].select(text=self.staff_group.name) @@ -189,7 +190,7 @@ def test_create_menu_item_as_superuser_with_document(self): document = mommy.make(InformationDocument) response = self.app.get(reverse('menu_item_create'), user=self.root_user) - form = response.form + form = response.forms['menu_item_edit'] form['title'] = 'test title' form['document'].select(text=document.title) form['group'].select(text=self.staff_group.name) @@ -208,7 +209,7 @@ def test_create_menu_item_as_normal_user_no_document_and_link(self): menu_item_count = MenuItem.objects.count() response = self.app.get(reverse('menu_item_create'), user=self.user) - form = response.form + form = response.forms['menu_item_edit'] form['group'].select(text=self.staff_group.name) response = form.submit() @@ -221,7 +222,7 @@ def test_create_menu_item_as_normal_user_with_document(self): document = mommy.make(InformationDocument) response = self.app.get(reverse('menu_item_create'), user=self.user) - form = response.form + form = response.forms['menu_item_edit'] form['title'] = 'test title' form['document'].select(text=document.title) form['group'].select(text=self.staff_group.name) @@ -237,7 +238,7 @@ def test_create_menu_item_as_normal_user_with_document_without_parent(self): document = mommy.make(InformationDocument) response = self.app.get(reverse('menu_item_create'), user=self.user) - form = response.form + form = response.forms['menu_item_edit'] form['title'] = 'test title' form['document'].select(text=document.title) form['group'].select(text=self.staff_group.name) @@ -253,7 +254,7 @@ def test_create_menu_wrong_group(self): group = mommy.make(Group) response = self.app.get(reverse('menu_item_create'), user=self.user) - form = response.form + form = response.forms['menu_item_edit'] form['title'] = 'test title' form['document'].select(text=document.title) form['group'].force_value(group.id) @@ -401,7 +402,7 @@ def test_menu_item_edit(self): original_menu_item = self.sub_item - form = response.form + form = response.forms['menu_item_edit'] form['title'] = 'Lorem Ipsum' form['document'] = document.pk @@ -522,3 +523,132 @@ def test_remind_users_about_due_unpublished_minutes_documents(self): management.call_command('send_reminders') self.assertEqual(len(mail.outbox), 2) + + +class TestSearch(WebTest): + csrf_checks = False + + @classmethod + def setUpTestData(cls): + cls.user = mommy.make(UserProfile, is_superuser=True) + + text1 = "both notO \n Case notB notO \n two notB notO \n two lines notB notO \n all three notB notO" + text2 = "in both minutes notO \n one notB \n substring notB notO \n all three notB notO" + text3 = "all three notB notO" + text4 = " something else" + text5 = "this will never show up notB notO" + + cls.minutes_document = mommy.make(MinutesDocument, text=text1, title="TestMinute") + cls.poll = mommy.make(Poll, text=text2, title="TestPoll") + cls.information_document = mommy.make(InformationDocument, text=text3, title="TestInformationDocument") + cls.minutes_document_w_script = mommy.make(MinutesDocument, text=text4, title="TestMinuteWithScript") + cls.information_document_never = mommy.make(InformationDocument, text=text5, title="TestInformationDocumentNever") + cls.group = mommy.make(Group) + cls.minutes_document.set_all_permissions(cls.group) + cls.poll.set_all_permissions(cls.group) + cls.information_document.set_all_permissions(cls.group) + cls.minutes_document_w_script.set_all_permissions(cls.group) + cls.information_document_never.set_all_permissions(cls.group) + + def test_no_permission(self): + user = mommy.make(UserProfile) + search_string = "both" + + response = self.app.get(reverse('index'), user=user) + form = response.forms["general_search"] + form.set('search_phrase', search_string) + + response = form.submit() + + self.assertIn('No documents containing "both" found.', response) + self.assertNotIn('TestMinute', response) + self.assertNotIn('TestPoll', response) + + def test_some_permissions(self): + user = mommy.make(UserProfile) + self.minutes_document.set_all_permissions(user) + search_string = "both" + + response = self.app.get(reverse('index'), user=user) + form = response.forms["general_search"] + form.set('search_phrase', search_string) + + response = form.submit() + + self.assertIn('TestMinute', response) + self.assertNotIn('TestPoll', response) + + def test_all_types_of_documents(self): + search_string = "all three" + + response = self.app.get(reverse('index'), user=self.user) + + form = response.forms["general_search"] + form.set('search_phrase', search_string) + + response = form.submit() + + self.assertIn('TestMinute', response) + self.assertIn('TestPoll', response) + self.assertIn('TestInformationDocument', response) + self.assertNotIn('TestInformationDocumentNever', response) + + def test_case_insensitive_result(self): + search_string = "case" + + response = self.app.get(reverse('index'), user=self.user) + + form = response.forms["general_search"] + form.set('search_phrase', search_string) + + response = form.submit() + + self.assertIn('TestMinute', response) + self.assertNotIn('TestPoll', response) + self.assertNotIn('TestInformationDocument', response) + self.assertNotIn('TestInformationDocumentNever', response) + + self.assertIn('Case notB notO', response) + + def test_substring_result(self): + search_string = "bstrin" + + response = self.app.get(reverse('index'), user=self.user) + + form = response.forms["general_search"] + form.set('search_phrase', search_string) + + response = form.submit() + + self.assertIn('TestPoll', response) + self.assertNotIn('TestMinute', response) + self.assertNotIn('TestInformationDocument', response) + self.assertNotIn('TestInformationDocumentNever', response) + + self.assertIn('substring notB notO', response) + + def test_nothing_found_message(self): + search_string = "not in the minutes" + + response = self.app.get(reverse('index'), user=self.user) + + form = response.forms["general_search"] + form.set('search_phrase', search_string) + + response = form.submit() + + self.assertIn('No documents containing "not in the minutes" found.', response.body.decode('utf-8')) + self.assertNotIn('notB', response) + self.assertNotIn('notO', response) + + def test_correct_escaping(self): + search_string = "" + + response = self.app.get(reverse('index'), user=self.user) + + form = response.forms["general_search"] + form.set('search_phrase', search_string) + + response = form.submit() + + self.assertIn('<script>alert(Hello);</script> something else', response.body.decode('utf-8')) diff --git a/_1327/main/views.py b/_1327/main/views.py index 389ed0ab..67dea4d5 100644 --- a/_1327/main/views.py +++ b/_1327/main/views.py @@ -1,4 +1,5 @@ import json +import re from django.conf import settings from django.contrib import messages @@ -9,6 +10,8 @@ from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, Http404, redirect, render from django.urls import reverse +from django.utils.html import escape +from django.utils.safestring import mark_safe from django.utils.translation import ugettext_lazy as _ from django.views.decorators.http import require_POST from guardian.shortcuts import get_objects_for_user @@ -170,3 +173,50 @@ def abbreviation_explanation_edit(request): return redirect('abbreviation_explanation') else: return render(request, "abbreviation_explanation.html", dict(formset=formset)) + + +def search(request): + search_text = None + if request.method == 'POST': + search_text = request.POST.get('search_phrase') + + if not search_text: + return render(request, "searched_list.html", { + 'searched_documents': [], + 'phrase': "", + }) + + # filter for documents that contain the searched for string + documents = Document.objects.filter(text__icontains=search_text) + + # find documents and lines containing the searched for string + result = {} + for d in documents: + # check if the user has permission to view the document + if not (request.user.has_perm(d.view_permission_name) or request.user.has_perm(d.view_permission_name, d)): + continue + # find lines with the searched for string and mark it as bold + lines = d.text.splitlines() + lines = [ + mark_safe( + re.sub( + r'(' + re.escape(escape(search_text)) + ')', + r'\1', escape(line), + flags=re.IGNORECASE + ) + ) + for line in lines if (line.casefold().find(search_text.casefold()) != -1) + ] + content_type = ContentType.objects.get_for_model(d) + + if content_type not in result: + result[content_type] = [] + + result[content_type].append((d, lines)) + + if 'Minutes' in result: + result['Minutes'].sort(key=lambda minute: minute[0].date, reverse=True) + return render(request, "searched_list.html", { + 'searched_documents': result.items(), + 'phrase': search_text, + }) diff --git a/_1327/minutes/migrations/0010_auto_20180813_2050_squashed_0011_auto_20180813_2102.py b/_1327/minutes/migrations/0010_auto_20180813_2050_squashed_0011_auto_20180813_2102.py new file mode 100644 index 00000000..f50040a6 --- /dev/null +++ b/_1327/minutes/migrations/0010_auto_20180813_2050_squashed_0011_auto_20180813_2102.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.15 on 2018-08-13 19:04 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + replaces = [('minutes', '0010_auto_20180813_2050'), ('minutes', '0011_auto_20180813_2102')] + + dependencies = [ + ('minutes', '0009_auto_20180201_2301'), + ] + + operations = [ + migrations.AlterModelOptions( + name='minutesdocument', + options={'base_manager_name': 'objects', 'permissions': (('view_minutesdocument', 'User/Group is allowed to view those minutes'),), 'verbose_name': 'Minutes', 'verbose_name_plural': 'Minutes'}, + ), + ] diff --git a/_1327/minutes/templates/minutes_list.html b/_1327/minutes/templates/minutes_list.html index 812e656b..4b305ba1 100644 --- a/_1327/minutes/templates/minutes_list.html +++ b/_1327/minutes/templates/minutes_list.html @@ -8,7 +8,7 @@ {% block sidebar %}