diff --git a/myhpi/core/markdown/extensions.py b/myhpi/core/markdown/extensions.py index 15ec98e9..2e51d4da 100644 --- a/myhpi/core/markdown/extensions.py +++ b/myhpi/core/markdown/extensions.py @@ -75,7 +75,7 @@ def breakify(self, match): ) -class QuorumPrepocessor(MinutesBasePreprocessor): +class QuorumPreprocessor(MinutesBasePreprocessor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.patterns = [ @@ -138,11 +138,11 @@ class HeadingLevelPreprocessor(MinutesBasePreprocessor): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.patterns = [ - (r"^#{1,5}", self.decrease), + (r"^#{1,5} ", self.decrease), ] def decrease(self, match): - return f"{match.group(0)}#" + return f"#{match.group(0)}" class InternalLinkPattern(LinkInlineProcessor): @@ -158,6 +158,9 @@ def handleMatch(self, m, data=None): def url(self, id): return Page.objects.get(id=id).localized.get_url() + def default_pattern(): + return r"\[(?P[^\[]+)\]\(page:(?P<id>\d+)\)" + class ImagePattern(LinkInlineProcessor): def handleMatch(self, m, data=None): @@ -174,6 +177,9 @@ def handleMatch(self, m, data=None): def url(self, id): return Image.objects.get(id=id).get_rendition("width-800").url + def default_pattern(): + return r"!\[(?P<title>[^\[]+)\]\(image:(?P<id>\d+)\)" + class MinuteExtension(Extension): def extendMarkdown(self, md): @@ -181,16 +187,16 @@ def extendMarkdown(self, md): md.preprocessors.register(VotePreprocessor(md), "votify", 200) md.preprocessors.register(StartEndPreprocessor(md), "start_or_endify", 200) md.preprocessors.register(BreakPreprocessor(md), "breakify", 200) - md.preprocessors.register(QuorumPrepocessor(md), "quorumify", 200) + md.preprocessors.register(QuorumPreprocessor(md), "quorumify", 200) md.preprocessors.register(EnterLeavePreprocessor(md), "enter_or_leavify", 200) md.preprocessors.register(HeadingLevelPreprocessor(md), "decrease", 200) md.inlinePatterns.register( - InternalLinkPattern(r"\[(?P<title>[^\[]+)\]\(page:(?P<id>\d+)\)", md), + InternalLinkPattern(InternalLinkPattern.default_pattern(), md), "InternalLinkPattern", 200, ) md.inlinePatterns.register( - ImagePattern(r"!\[(?P<title>[^\[]+)\]\(image:(?P<id>\d+)\)", md), + ImagePattern(ImagePattern.default_pattern(), md), "ImagePattern", 200, ) diff --git a/myhpi/polls/models.py b/myhpi/polls/models.py index 317a89ba..d43680d4 100644 --- a/myhpi/polls/models.py +++ b/myhpi/polls/models.py @@ -65,18 +65,25 @@ def serve(self, request, *args, **kwargs): elif request.method == "POST" and self.can_vote(request.user): choices = request.POST.getlist("choice") if len(choices) == 0: - messages.error(request, "You must at least select one choice.") + messages.error(request, "You must select at least one choice.") elif len(choices) > self.max_allowed_answers: messages.error( request, "You can only select up to {} options.".format(self.max_allowed_answers), ) else: + confirmed_choices = 0 for choice_id in choices: - choice = self.choices.filter(id=choice_id) - choice.update(votes=F("votes") + 1) - self.participants.add(request.user) - messages.success(request, "Your vote has been counted.") + choice = self.choices.filter(id=choice_id).first() + if choice and choice.page == self: + choice.votes += 1 + choice.save() + confirmed_choices += 1 + else: + messages.error(request, "Invalid choice.") + if confirmed_choices > 0: + self.participants.add(request.user) + messages.success(request, "Your vote has been counted.") return redirect(self.relative_url(self.get_site())) return super().serve(request, *args, **kwargs) diff --git a/myhpi/tests/core/setup.py b/myhpi/tests/core/setup.py index eb0eb575..f13e71f0 100644 --- a/myhpi/tests/core/setup.py +++ b/myhpi/tests/core/setup.py @@ -1,5 +1,6 @@ -from django.contrib.auth.models import Group, User -from wagtail.models import Site +from django.contrib.auth.models import Group, Permission, User +from wagtail.documents.models import Document +from wagtail.models import Collection, GroupCollectionPermission, Site from myhpi.core.models import ( FirstLevelMenuItem, @@ -41,7 +42,7 @@ def create_users(): def create_groups(users): superuser, student, student_representative = users - students = Group.objects.create(name="Students") + students = Group.objects.create(name="Student") fsr = Group.objects.create(name="Student Representative Group") students.user_set.add(superuser) @@ -228,12 +229,70 @@ def setup_minutes(group, students_group, parent, user): return minutes, minutes_list +def create_collections(groups): + root_collection = Collection.get_first_root_node() + for group in groups: + group_collection = root_collection.add_child(name=f"{group.name} collection") + group_collection.save() + GroupCollectionPermission.objects.create( + group=group, + collection=group_collection, + permission=Permission.objects.get( + content_type__app_label="wagtaildocs", codename="add_document" + ), + ) + GroupCollectionPermission.objects.create( + group=group, + collection=group_collection, + permission=Permission.objects.get( + content_type__app_label="wagtaildocs", codename="change_document" + ), + ) + GroupCollectionPermission.objects.create( + group=group, + collection=group_collection, + permission=Permission.objects.get( + content_type__app_label="wagtaildocs", codename="choose_document" + ), + ) + yield group_collection + + +def get_test_image_file(): + from django.core.files.uploadedfile import SimpleUploadedFile + + image_file = SimpleUploadedFile( + name="test_image.jpg", + content=open("myhpi/tests/files/test_image.jpg", "rb").read(), + content_type="image/jpeg", + ) + return image_file + + +def create_documents(collections): + documents = [ + Document.objects.create( + title="First document", + file=get_test_image_file(), + collection=collections[0], + ), + Document.objects.create( + title="Second document", + file=get_test_image_file(), + collection=collections[1], + ), + ] + return documents + + def setup_data(): users = create_users() groups = create_groups(users) basic_pages = create_basic_page_structure() information_pages = create_information_pages(groups, basic_pages["information_menu"]) minutes, minutes_list = setup_minutes(groups[1], groups[0], basic_pages["fsr_menu"], users[2]) + collections = list(create_collections(groups)) + documents = create_documents(collections) return { "basic_pages": basic_pages, @@ -242,4 +301,6 @@ def setup_data(): "pages": information_pages, "minutes": minutes, "minutes_list": minutes_list, + "collections": collections, + "documents": documents, } diff --git a/myhpi/tests/core/test_auth.py b/myhpi/tests/core/test_auth.py new file mode 100644 index 00000000..d81e7b4b --- /dev/null +++ b/myhpi/tests/core/test_auth.py @@ -0,0 +1,49 @@ +from myhpi.core.auth import MyHPIOIDCAB +from myhpi.tests.core.utils import MyHPIPageTestCase + + +class AuthTests(MyHPIPageTestCase): + def setUp(self): + super().setUp() + self.auth_backend = MyHPIOIDCAB() + + def test_create_user(self): + claims = { + "email": "ali.gator@example.org", + "given_name": "Ali", + "family_name": "Gator", + "sub": "ali.gator", + } + user = self.auth_backend.create_user(claims) + self.assertEqual(user.username, "ali.gator") + self.assertFalse(user.groups.filter(name="Student").exists()) + + matching_users = self.auth_backend.filter_users_by_claims(claims) + self.assertEqual(len(matching_users), 1) + + def test_create_student(self): + claims = { + "email": "grace.hopper@student.uni-potsdam.example.com", + "given_name": "Grace", + "family_name": "Hopper", + "sub": "grace.hopper", + } + user = self.auth_backend.create_user(claims) + self.assertEqual(user.username, "grace.hopper") + self.assertEqual(user.email, "grace.hopper@student.example.com") + self.assertTrue(user.groups.filter(name="Student").exists()) + + def test_update_user(self): + claims = { + "email": "jw.goethe@weimar.de", + "given_name": "Johann Wolfgang", + "family_name": "Goethe", + "sub": "jw.goethe", + } + user = self.auth_backend.create_user(claims) + self.assertEqual(user.username, "jw.goethe") + self.assertEqual(user.last_name, "Goethe") + claims["family_name"] = "von Goethe" + user = self.auth_backend.update_user(user, claims) + self.assertEqual(user.first_name, "Johann Wolfgang") + self.assertEqual(user.last_name, "von Goethe") diff --git a/myhpi/tests/core/test_email_utils.py b/myhpi/tests/core/test_email_utils.py new file mode 100644 index 00000000..5ac5d74f --- /dev/null +++ b/myhpi/tests/core/test_email_utils.py @@ -0,0 +1,41 @@ +from unittest import TestCase + +from myhpi import settings +from myhpi.core.utils import ( + alternative_emails, + email_belongs_to_domain, + replace_email_domain, + toggle_institution, +) + + +class EmailUtilTest(TestCase): + def test_email_belongs_to_domain(self): + emails = ["abc@example.com", "abc@myhpi.de"] + domains = ["example.com", "myhpi.de"] + for email, domain in zip(emails, domains): + self.assertTrue(email_belongs_to_domain(email, domain)) + self.assertFalse(email_belongs_to_domain(emails[0], domains[1])) + + def test_replace_email_domain(self): + email = "abc@example.com" + original_domain = "example.com" + new_domain = "myhpi.de" + self.assertEqual(replace_email_domain(email, original_domain, new_domain), "abc@myhpi.de") + + def test_toggle_institution(self): + emails = ["user1@hpi.uni-potsdam.de", "user2@unrelated.com", "user3@hpi.de"] + expected = ["user1@hpi.de", "user2@unrelated.com", "user3@hpi.uni-potsdam.de"] + for email, expected_email in zip(emails, expected): + toggled = list(toggle_institution(email)) + if not "unrelated" in email: + self.assertEqual(toggled[0], expected_email) + + def test_alternative_emails(self): + email = "user@hpi.de" + alternatives = [ + "user@hpi.uni-potsdam.de", + "user@student.hpi.de", + "user@student.hpi.uni-potsdam.de", + ] + self.assertSetEqual(set(alternative_emails(email)), set(alternatives)) diff --git a/myhpi/tests/core/test_polls.py b/myhpi/tests/core/test_polls.py new file mode 100644 index 00000000..5b844ed7 --- /dev/null +++ b/myhpi/tests/core/test_polls.py @@ -0,0 +1,105 @@ +from datetime import datetime, timedelta + +from myhpi.polls.models import Poll, PollChoice, PollList +from myhpi.tests.core.utils import MyHPIPageTestCase + + +class PollTests(MyHPIPageTestCase): + def setUp(self): + super().setUp() + self.poll_list = PollList( + title="Polls", + slug="polls", + path="0001000200010005", + depth=4, + is_public=True, + ) + self.information_menu.add_child(instance=self.poll_list) + + self.poll = Poll( + title="How are you?", + slug="how-are-you", + question="How are you?", + description="This is a poll to check how you are.", + start_date=datetime.now() - timedelta(days=1), + end_date=datetime.now() + timedelta(days=1), + max_allowed_answers=1, + results_visible=False, + is_public=True, + ) + + self.poll_list.add_child(instance=self.poll) + + self.choice_good = PollChoice( + text="Good", + page=self.poll, + votes=0, + ) + self.choice_good.save() + self.choice_bad = PollChoice( + text="Bad", + page=self.poll, + votes=0, + ) + self.choice_bad.save() + + def test_can_vote_once(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + self.poll.participants.add(self.student) + self.assertFalse(self.poll.can_vote(self.student)) + + def test_post_vote(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + self.client.post( + self.poll.url, + data={"choice": [self.choice_good.id]}, + ) + self.choice_good.refresh_from_db() + self.assertEqual(self.choice_good.votes, 1) + self.assertEqual(self.choice_good.percentage(), 100) + self.assertEqual(self.choice_bad.percentage(), 0) + self.assertFalse(self.poll.can_vote(self.student)) + + def test_post_vote_invalid_choice(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + self.client.post( + self.poll.url, + data={"choice": [self.choice_good.id + 9999]}, + ) + self.choice_good.refresh_from_db() + self.assertEqual(self.choice_good.votes, 0) + self.assertTrue(self.poll.can_vote(self.student)) + + def test_post_vote_no_choice(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + response = self.client.post( + self.poll.url, + data={"choice": []}, + ) + self.assertContains(response, "You must select at least one choice.") + self.assertTrue(self.poll.can_vote(self.student)) + + def test_post_vote_too_many_choices(self): + self.sign_in_as_student() + self.assertTrue(self.poll.can_vote(self.student)) + response = self.client.post( + self.poll.url, + data={"choice": [self.choice_good.id, self.choice_bad.id]}, + ) + self.assertContains(response, "You can only select up to 1 options.", 1) + self.assertTrue(self.poll.can_vote(self.student)) + + def test_post_vote_before_start_date(self): + self.sign_in_as_student() + self.poll.start_date = datetime.now() + timedelta(days=1) + self.poll.save() + self.assertTrue(self.poll.can_vote(self.student)) + response = self.client.post( + self.poll.url, data={"choice": [self.choice_good.id]}, follow=True + ) + self.assertContains(response, "This poll has not yet started.") + self.assertTrue(self.poll.can_vote(self.student)) diff --git a/myhpi/tests/core/test_view_permissions.py b/myhpi/tests/core/test_view_permissions.py index 5164424d..3125d500 100644 --- a/myhpi/tests/core/test_view_permissions.py +++ b/myhpi/tests/core/test_view_permissions.py @@ -52,3 +52,16 @@ def test_super_user_can_view_all_pages(self): self.assertEqual(response.status_code, 200) response = self.client.get(self.private_page.url, follow=True) self.assertEqual(response.status_code, 200) + + def test_document_view(self): + self.common_page.attachments.add(self.first_document) + self.common_page.save() + self.private_page.attachments.add(self.second_document) + self.private_page.save() + + self.sign_in_as_student() + response = self.client.get(self.first_document.url, follow=True) + self.assertEqual(response.status_code, 200) + + response = self.client.get(self.second_document.url, follow=True) + self.assertEqual(response.status_code, 403) diff --git a/myhpi/tests/core/test_widgets.py b/myhpi/tests/core/test_widgets.py new file mode 100644 index 00000000..85e69e73 --- /dev/null +++ b/myhpi/tests/core/test_widgets.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass + +from django.forms.models import ModelChoiceIteratorValue + +from myhpi.core.widgets import AttachmentSelectWidget +from myhpi.tests.core.utils import MyHPIPageTestCase + + +class WidgetTests(MyHPIPageTestCase): + def setUp(self): + super().setUp() + + def test_attachment_select_widget(self): + self.sign_in_as_student() + # student has access to document 1, not document 2 + choices = list( + map( + lambda doc: (ModelChoiceIteratorValue(doc.id, doc), doc.title), + self.test_data["documents"], + ) + ) + widget = AttachmentSelectWidget(user=self.student, choices=choices) + optgroups = widget.optgroups("attachments", []) + self.assertEqual(len(optgroups), 1) + + widget = AttachmentSelectWidget(user=self.student_representative, choices=choices) + optgroups = widget.optgroups("attachments", []) + self.assertEqual(len(optgroups), 2) diff --git a/myhpi/tests/core/utils.py b/myhpi/tests/core/utils.py index a7f03af4..396dbf48 100644 --- a/myhpi/tests/core/utils.py +++ b/myhpi/tests/core/utils.py @@ -27,6 +27,9 @@ def setUp(self): self.student = self.test_data["users"][1] self.student_representative = self.test_data["users"][2] + self.first_document = self.test_data["documents"][0] + self.second_document = self.test_data["documents"][1] + def sign_in_as_student(self): self.client.force_login(self.student) diff --git a/myhpi/tests/files/test_image.jpg b/myhpi/tests/files/test_image.jpg new file mode 100644 index 00000000..056b24f2 Binary files /dev/null and b/myhpi/tests/files/test_image.jpg differ diff --git a/myhpi/tests/test_markdown_extensions.py b/myhpi/tests/test_markdown_extensions.py new file mode 100644 index 00000000..2a418063 --- /dev/null +++ b/myhpi/tests/test_markdown_extensions.py @@ -0,0 +1,130 @@ +import re +from typing import Collection + +import django +from django.test import TestCase +from django.utils.translation import activate +from wagtail.core.models import Page +from wagtail.images.models import Image + +from myhpi.core.markdown.extensions import ImagePattern + +django.setup() + +from myhpi.core.markdown.extensions import ( + BreakPreprocessor, + EnterLeavePreprocessor, + HeadingLevelPreprocessor, + InternalLinkPattern, + QuorumPreprocessor, + StartEndPreprocessor, + VotePreprocessor, +) + + +class TestMarkdownExtensions(TestCase): + def test_vote_preprocessor(self): + vp = VotePreprocessor() + text = ["[2|3|4]", "[a|b|c]"] # Second line is not processed + result = vp.run(text) + self.assertEqual(result, ["**[2|3|4]**", "[a|b|c]"]) + + def test_start_end_preprocessor(self): + activate("en") + sep = StartEndPreprocessor() + text = ["|start|(12:00)", "|end|(16:00)"] + result = sep.run(text) + self.assertEqual(result, ["*Begin of meeting: 12:00* ", "*End of meeting: 16:00* "]) + + def test_break_preprocessor(self): + activate("en") + bp = BreakPreprocessor() + text = ["|break|(12:00)(13:00)"] + result = bp.run(text) + self.assertEqual(result, ["*Meeting break: 12:00 – 13:00*"]) + + def test_quorum_preprocessor(self): + activate("en") + qp = QuorumPreprocessor() + text = ["|quorum|(3/8)", "|quorum|(4/8)"] + result = qp.run(text) + self.assertEqual(result, ["*3/8 present → not quorate* ", "*4/8 present → quorate* "]) + + def test_enter_leave_preprocessor(self): + activate("en") + elp = EnterLeavePreprocessor() + text = [ + "|enter|(12:00)(First Last)", + "|enter|(12:00)(Prof. First Last)", + "|enter|(12:00)(Prof. First Last)(Means)", + "|leave|(12:00)(Prof. First Last)", + ] + result = elp.run(text) + self.assertEqual( + result, + [ + "*12:00: First Last enters the meeting* ", + "*12:00: Prof. First Last enters the meeting* ", + "*12:00: Prof. First Last enters the meeting via Means* ", + "*12:00: Prof. First Last leaves the meeting* ", + ], + ) + + def test_heading_level_preprocessor(self): + hlp = HeadingLevelPreprocessor() + text = [ + "# Heading 1", + "## Heading 2", + "### Heading 3", + "#### Heading 4", + "##### Heading 5", + "###### Heading 6", + ] + result = hlp.run(text) + self.assertEqual( + result, + [ + "## Heading 1", + "### Heading 2", + "#### Heading 3", + "##### Heading 4", + "###### Heading 5", + "###### Heading 6", + ], + ) + + def test_internal_link_preprocessor(self): + ilp = InternalLinkPattern(InternalLinkPattern.default_pattern()) + + from myhpi.tests.core.setup import setup_data + + test_data = setup_data() + test_page = test_data["pages"][0] + text = f"[Page title](page:{test_page.id})" + el, _, _ = ilp.handleMatch(re.match(ilp.pattern, text)) + self.assertEqual(el.attrib["href"], test_page.localized.get_url()) + + def test_image_pattern(self): + activate("en") + from django.core.files.uploadedfile import SimpleUploadedFile + + ip = ImagePattern(ImagePattern.default_pattern()) + + image_file = SimpleUploadedFile( + name="test_image.jpg", + content=open("myhpi/tests/files/test_image.jpg", "rb").read(), + content_type="image/jpeg", + ) + + image = Image.objects.create( + title="Test image", + file=image_file, + ) + + text = f"![Alt text](image:{image.id})" + invalid_text = "![Alt text](image:1234567890)" + el, _, _ = ip.handleMatch(re.match(ip.pattern, text)) + self.assertEqual(el.attrib["src"], image.get_rendition("width-800").url) + + el, _, _ = ip.handleMatch(re.match(ip.pattern, invalid_text)) + self.assertEqual(el.text, "[missing image]") diff --git a/myhpi/tests/test_minutes_extensions.py b/myhpi/tests/test_minutes_extensions.py deleted file mode 100644 index 0bb0e118..00000000 --- a/myhpi/tests/test_minutes_extensions.py +++ /dev/null @@ -1,29 +0,0 @@ -import django -from django.test import TestCase -from django.utils.translation import activate - -django.setup() - -from myhpi.core.markdown.extensions import EnterLeavePreprocessor - - -class TestMinuteExtensions(TestCase): - def test_enter(self): - activate("en") - elp = EnterLeavePreprocessor() - text = [ - "|enter|(12:00)(First Last)", - "|enter|(12:00)(Prof. First Last)", - "|enter|(12:00)(Prof. First Last)(Means)", - "|leave|(12:00)(Prof. First Last)", - ] - result = elp.run(text) - self.assertEqual( - result, - [ - "*12:00: First Last enters the meeting* ", - "*12:00: Prof. First Last enters the meeting* ", - "*12:00: Prof. First Last enters the meeting via Means* ", - "*12:00: Prof. First Last leaves the meeting* ", - ], - )