├── myhpi ├── __init__.py ├── core │ ├── __init__.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ └── __init__.py │ ├── markdown │ │ ├── __init__.py │ │ ├── fields.py │ │ └── utils.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0008_remove_footer_column_4.py │ │ ├── 0013_translate_abbreviationexplanations.py │ │ ├── 0010_minutes_location.py │ │ ├── 0004_minuteslabel_color.py │ │ ├── 0009_alter_minuteslabel_slug.py │ │ ├── 0011_alter_taggedminutes_tag.py │ │ ├── 0012_abbreviationexplanation_locale_and_more.py │ │ ├── 0007_alter_footer_column_1_alter_footer_column_2_and_more.py │ │ ├── 0014_alter_abbreviationexplanation_locale_and_more.py │ │ ├── 0006_informationpage_attachments_minutes_attachments_and_more.py │ │ ├── 0005_redirectmenuitem.py │ │ ├── 0003_minutes_guests_alter_informationpage_body_and_more.py │ │ └── 0002_rootpage.py │ ├── templatetags │ │ ├── __init__.py │ │ ├── minutes_tags.py │ │ └── core_extras.py │ ├── admin.py │ ├── tests.py │ ├── urls.py │ ├── templates │ │ └── core │ │ │ ├── label.html │ │ │ ├── toc_button.html │ │ │ ├── text_array_widget.html │ │ │ ├── information_page.html │ │ │ ├── sidebar.html │ │ │ ├── minutes_list.html │ │ │ └── minutes.html │ ├── views.py │ ├── middleware.py │ ├── wagtail_hooks.py │ ├── utils.py │ ├── context.py │ ├── auth.py │ └── widgets.py ├── polls │ ├── __init__.py │ ├── views.py │ ├── migrations │ │ ├── __init__.py │ │ ├── 0005_remove_pollchoice_description.py │ │ ├── 0009_alter_poll_description.py │ │ ├── 0010_remove_pollchoice_page_delete_poll_delete_pollchoice.py │ │ ├── 0007_auto_20210612_1620.py │ │ ├── 0006_auto_20210612_1547.py │ │ ├── 0004_poll_participants.py │ │ ├── 0003_auto_20210612_1231.py │ │ ├── 0002_auto_20210612_1217.py │ │ ├── 0013_assign_translation_keys.py │ │ ├── 0008_polllist.py │ │ ├── 0012_alter_majorityvotechoice_options_and_more.py │ │ ├── 0001_initial.py │ │ └── 0014_enforce_translation_key_locale_unique.py │ ├── tests.py │ ├── wagtail_hooks.py │ ├── admin.py │ ├── templates │ │ └── polls │ │ │ ├── poll_list.html │ │ │ ├── majority_vote_poll.html │ │ │ ├── base_poll.html │ │ │ └── ranked_choice_poll.html │ ├── templatetags │ │ └── polls.py │ └── forms.py ├── tests │ ├── __init__.py │ ├── core │ │ ├── __init__.py │ │ ├── test_metrics.py │ │ ├── test_widgets.py │ │ ├── test_redirect_menu_item.py │ │ ├── test_minute_lists.py │ │ ├── test_email_utils.py │ │ ├── test_search.py │ │ ├── utils.py │ │ ├── test_auth.py │ │ ├── test_page_creation.py │ │ ├── test_minutes.py │ │ ├── test_menu.py │ │ └── test_view_permissions.py │ ├── polls │ │ ├── __init__.py │ │ └── test_ranked_choice_localization.py │ ├── test_example.py │ ├── files │ │ └── test_image.jpg │ ├── settings.py │ └── test_markdown_extensions.py ├── search │ ├── __init__.py │ ├── templates │ │ └── search │ │ │ ├── search_field.html │ │ │ ├── search_result.html │ │ │ └── search.html │ ├── views.py │ └── templatetags │ │ └── search.py ├── tenca_django │ ├── migrations │ │ ├── __init__.py │ │ ├── 0002_alter_hashentry_options_delete_legacyadminurl.py │ │ └── 0001_initial.py │ ├── templatetags │ │ ├── __init__.py │ │ └── tenca_extras.py │ ├── __init__.py │ ├── apps.py │ ├── templates │ │ └── tenca_django │ │ │ ├── backend_error.html │ │ │ ├── report.html │ │ │ ├── action.html │ │ │ ├── delete_list.html │ │ │ ├── manage_subscription.html │ │ │ ├── dashboard.html │ │ │ └── manage_list.html │ ├── README.md │ ├── connection.py │ ├── wagtail_hooks.py │ ├── middleware.py │ ├── urls.py │ ├── tests.py │ ├── mixins.py │ ├── forms.py │ └── models.py ├── static │ ├── security.txt │ ├── js │ │ ├── utils.js │ │ ├── sidebar.js │ │ ├── search.js │ │ ├── admin │ │ │ ├── text_array_widget.js │ │ │ └── easymde_custom.js │ │ ├── print_processor.js │ │ └── myHPI.js │ ├── css │ │ ├── text_array_widget.css │ │ └── myHPI_admin.css │ ├── scss │ │ └── footer.scss │ └── img │ │ ├── favicon │ │ └── favicon.svg │ │ └── myHPI-Logo.svg ├── wsgi.py ├── templates │ ├── 404.html │ ├── 500.html │ ├── login.html │ ├── footer.html │ ├── nav_level.html │ └── 403.html └── urls.py ├── uwsgi.ini ├── .prettierrc ├── .github ├── dependabot.yml └── workflows │ ├── build-container.yml │ ├── codeql-analysis.yml │ └── tests.yml ├── entrypoint.sh ├── .devcontainer ├── setup.sh ├── Dockerfile └── devcontainer.json ├── manage.py ├── Dockerfile ├── .pre-commit-config.yaml ├── .env.example ├── pyproject.toml ├── .gitignore ├── README.md └── tools └── install_bootstrap.py /myhpi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/polls/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/polls/views.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/search/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/tests/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/core/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/core/markdown/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/tests/polls/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/core/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/core/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/tenca_django/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/tenca_django/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /myhpi/core/admin.py: -------------------------------------------------------------------------------- 1 | # Register your models here. 2 | -------------------------------------------------------------------------------- /myhpi/core/tests.py: -------------------------------------------------------------------------------- 1 | # Create your tests here. 2 | -------------------------------------------------------------------------------- /myhpi/polls/tests.py: -------------------------------------------------------------------------------- 1 | # Create your tests here. 2 | -------------------------------------------------------------------------------- /myhpi/tests/test_example.py: -------------------------------------------------------------------------------- 1 | def test_example(): 2 | assert 1 + 1 == 2 3 | -------------------------------------------------------------------------------- /uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | socket=0.0.0.0:8000 3 | wsgi-file=/app/myhpi/wsgi.py 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "semi": false, 4 | "singleQuote": false 5 | } 6 | -------------------------------------------------------------------------------- /myhpi/tenca_django/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "myhpi.tenca_django.apps.TencaDjangoConfig" 2 | -------------------------------------------------------------------------------- /myhpi/tests/files/test_image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fsr-de/myHPI/HEAD/myhpi/tests/files/test_image.jpg -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /myhpi/static/security.txt: -------------------------------------------------------------------------------- 1 | Contact: https://github.com/fsr-de/myHPI/security/advisories/new 2 | Expires: 2024-10-18T10:00:00.000Z 3 | Preferred-Languages: en,de 4 | -------------------------------------------------------------------------------- /myhpi/core/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from myhpi.core.views import metrics_view 4 | 5 | urlpatterns = [path("metrics", metrics_view, name="prometheus-django-metrics")] 6 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | python manage.py migrate 4 | python manage.py compilestatic 5 | python manage.py collectstatic --no-input 6 | python manage.py compilemessages 7 | 8 | exec "$@" 9 | -------------------------------------------------------------------------------- /myhpi/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myhpi.settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /myhpi/tenca_django/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class TencaDjangoConfig(AppConfig): 6 | name = "myhpi.tenca_django" 7 | verbose_name = _("Mailing Lists") 8 | -------------------------------------------------------------------------------- /myhpi/core/templates/core/label.html: -------------------------------------------------------------------------------- 1 | {% if minutes.labels.all %} 2 | {% for label in minutes.labels.all %} 3 | {{ label }} 4 | {% endfor %} 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /myhpi/tests/settings.py: -------------------------------------------------------------------------------- 1 | from myhpi.settings import * # NOQA, otherwise it will be removed by autoflake in the pre-commit hook 2 | 3 | STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage" 4 | METRICS_API_KEY = "TEST_KEY" 5 | LANGUAGE_CODE = "en" 6 | -------------------------------------------------------------------------------- /myhpi/polls/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from wagtail import hooks 2 | 3 | from myhpi.polls.models import BasePoll, PollList 4 | 5 | 6 | @hooks.register("before_serve_page") 7 | def check_view_permissions(page, request, serve_args, serve_kwargs): 8 | if isinstance(page, (PollList, BasePoll)): 9 | page.specific.check_can_view(request) 10 | -------------------------------------------------------------------------------- /myhpi/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block body_class %}template-404{% endblock %} 5 | 6 | {% block content %} 7 |

{% translate "Page not found" %}

8 | 9 |

10 | {% translate "The page you requested does not exist. Please check the URL or use the menu above to find your page." %} 11 |

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /myhpi/tenca_django/templates/tenca_django/backend_error.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %} 5 | {% trans "Mailing Lists Unavailable" %} 6 | {% endblock %} 7 | 8 | {% block content %} 9 |

10 | {% trans "Sorry for the inconvenience, but the mail server backend does not reply. Please try again later." %} 11 |

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /myhpi/polls/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from myhpi.polls.models import ( 4 | RankedChoiceBallot, 5 | RankedChoiceBallotEntry, 6 | RankedChoiceOption, 7 | RankedChoicePoll, 8 | ) 9 | 10 | admin.site.register(RankedChoiceBallot) 11 | admin.site.register(RankedChoiceBallotEntry) 12 | admin.site.register(RankedChoiceOption) 13 | admin.site.register(RankedChoicePoll) 14 | -------------------------------------------------------------------------------- /myhpi/static/js/utils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a given rem value into pixel. 3 | * 4 | * @param {number} rem Rem value to convert. 5 | * @returns Number of pixel the given rem value corresponds to. 6 | * 7 | * Source: https://stackoverflow.com/questions/36532307/rem-px-in-javascript 8 | */ 9 | function remToPx(rem) { 10 | return rem * parseFloat(getComputedStyle(document.documentElement).fontSize) 11 | } 12 | -------------------------------------------------------------------------------- /.devcontainer/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python -m venv env 3 | source env/bin/activate 4 | poetry install 5 | if python tools/install_bootstrap.py --is-installed; then 6 | echo "Bootstrap is already installed." 7 | else 8 | python tools/install_bootstrap.py 9 | fi 10 | 11 | if [ ! -f .env ]; then 12 | cp .env.example .env 13 | fi 14 | 15 | python manage.py migrate 16 | python manage.py compilemessages 17 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | if "test" in sys.argv: 7 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myhpi.tests.settings") 8 | else: 9 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myhpi.settings") 10 | 11 | from django.core.management import execute_from_command_line 12 | 13 | execute_from_command_line(sys.argv) 14 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0005_remove_pollchoice_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-06-12 13:06 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("polls", "0004_poll_participants"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="pollchoice", 14 | name="description", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0008_remove_footer_column_4.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2023-08-15 19:35 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("core", "0007_alter_footer_column_1_alter_footer_column_2_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="footer", 14 | name="column_4", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0013_translate_abbreviationexplanations.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-11-22 14:42 2 | 3 | from django.db import migrations 4 | from wagtail.models import BootstrapTranslatableModel 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("core", "0012_abbreviationexplanation_locale_and_more"), 11 | ] 12 | 13 | operations = [ 14 | BootstrapTranslatableModel("core.AbbreviationExplanation"), 15 | ] 16 | -------------------------------------------------------------------------------- /myhpi/tenca_django/templatetags/tenca_extras.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.filter 7 | def fqdn_ize(list_id): 8 | # template tags are loaded on django start-up, before a connection can be made 9 | from myhpi.tenca_django.connection import connection 10 | 11 | return connection.fqdn_ize(list_id) 12 | 13 | 14 | @register.filter(name="addcss") 15 | def addcss(field, css): 16 | return field.as_widget(attrs={"class": css}) 17 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0010_minutes_location.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-01-27 21:59 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("core", "0009_alter_minuteslabel_slug"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="minutes", 14 | name="location", 15 | field=models.CharField(blank=True, max_length=255), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /myhpi/templates/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Internal server error 6 | 7 | 8 | 9 | 10 |

Internal server error

11 | 12 |

Sorry, there seems to be an error. Please try again soon.

13 | 14 | 15 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:3.11 2 | 3 | # Add more tools needed in the devcontainer here 4 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 5 | && apt-get -y install --no-install-recommends \ 6 | nano \ 7 | pipx \ 8 | gettext \ 9 | && true 10 | 11 | ENV POETRY_VERSION=1.3.2 12 | 13 | # README steps 14 | RUN pip install "poetry==$POETRY_VERSION" 15 | RUN poetry self add "poetry-dynamic-versioning[plugin]" 16 | 17 | ENV SHELL=/bin/bash 18 | -------------------------------------------------------------------------------- /myhpi/tenca_django/README.md: -------------------------------------------------------------------------------- 1 | # tenca-django 2 | 3 | This app is used for the management of mailing lists. 4 | 5 | It is adapted from the [tenca-django implementation in 1327](https://github.com/fsr-de/1327/tree/master/_1327/tenca_django), originally implemented by @jeriox, @tzwenn, @Paula-Kli, @T4rikA and @SilvanVerhoeven. 6 | 7 | To enable mailing list management set the `ENABLE_MAILING_LISTS` setting to True in your .env file. 8 | Mailing lists can be managed by admins within wagtail. Mailing list owners can also manage mailing lists on the website. 9 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0004_minuteslabel_color.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-18 13:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("core", "0003_minutes_guests_alter_informationpage_body_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="minuteslabel", 14 | name="color", 15 | field=models.CharField(default="#000000", max_length=7), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0009_alter_poll_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-15 19:17 2 | 3 | from django.db import migrations 4 | 5 | import myhpi.core.markdown.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("polls", "0008_polllist"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="poll", 16 | name="description", 17 | field=myhpi.core.markdown.fields.CustomMarkdownField(), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /myhpi/core/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | import prometheus_client 3 | from django.conf import settings 4 | from django.http import HttpResponse 5 | 6 | 7 | def metrics_view(request): 8 | provided_key = request.headers.get("X-API-KEY") 9 | if provided_key and provided_key == settings.METRICS_API_KEY: 10 | metrics_page = prometheus_client.generate_latest(prometheus_client.REGISTRY) 11 | return HttpResponse(metrics_page, content_type=prometheus_client.CONTENT_TYPE_LATEST) 12 | else: 13 | return HttpResponse("Unauthorized", status=401) 14 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0009_alter_minuteslabel_slug.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-01-12 12:07 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("core", "0008_remove_footer_column_4"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="minuteslabel", 14 | name="slug", 15 | field=models.SlugField( 16 | allow_unicode=True, max_length=100, unique=True, verbose_name="slug" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_metrics.py: -------------------------------------------------------------------------------- 1 | from myhpi.tests.core.utils import MyHPIPageTestCase 2 | 3 | 4 | class MetricsTests(MyHPIPageTestCase): 5 | def test_unauthorized_metrics(self): 6 | with self.settings(METRICS_API_KEY="TEST_KEY"): 7 | response = self.client.get("/metrics") 8 | self.assertEqual(response.status_code, 401) 9 | 10 | def test_authorized_metrics(self): 11 | with self.settings(METRICS_API_KEY="TEST_KEY"): 12 | response = self.client.get("/metrics", **{"HTTP_X-API-KEY": "TEST_KEY"}) 13 | self.assertEqual(response.status_code, 200) 14 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0010_remove_pollchoice_page_delete_poll_delete_pollchoice.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2024-01-25 15:15 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("polls", "0009_alter_poll_description"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="pollchoice", 14 | name="page", 15 | ), 16 | migrations.DeleteModel( 17 | name="Poll", 18 | ), 19 | migrations.DeleteModel( 20 | name="PollChoice", 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /myhpi/static/css/text_array_widget.css: -------------------------------------------------------------------------------- 1 | .text-array > div { 2 | display: flex; 3 | align-items: center; 4 | margin: 5px 0; 5 | } 6 | 7 | .text-array > div > span { 8 | background-color: palevioletred; 9 | color: white; 10 | height: 2em; 11 | width: 2em; 12 | border-radius: 1em; 13 | margin-left: 8px; 14 | display: flex; 15 | align-items: center; 16 | justify-content: center; 17 | } 18 | 19 | .text-array-button { 20 | background-color: limegreen; 21 | color: white; 22 | height: 2em; 23 | width: 2em; 24 | border-radius: 1em; 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | } 29 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0007_auto_20210612_1620.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-06-12 14:20 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 10 | ("polls", "0006_auto_20210612_1547"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="poll", 16 | name="participants", 17 | field=models.ManyToManyField(related_name="polls", to=settings.AUTH_USER_MODEL), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | FROM python:3.11 3 | ENV PYTHONDONTWRITEBYTECODE=1 4 | ENV PYTHONUNBUFFERED=1 5 | ENV POETRY_VIRTUALENVS_CREATE=false 6 | ENV POETRY_VERSION=1.3.2 7 | ENV UWSGI_PIP_VERSION=2.0.21 8 | ENV PSYCOPG2_PIP_VERSION=2.9.3 9 | 10 | WORKDIR /app 11 | RUN apt update && apt install gettext -y 12 | RUN pip install "poetry==$POETRY_VERSION" "uwsgi==$UWSGI_PIP_VERSION" "psycopg2==$PSYCOPG2_PIP_VERSION" 13 | RUN poetry self add "poetry-dynamic-versioning[plugin]" 14 | ADD . /app 15 | RUN poetry install --no-dev 16 | RUN python tools/install_bootstrap.py -u 17 | RUN chmod +x /app/entrypoint.sh 18 | ENTRYPOINT ["/app/entrypoint.sh"] 19 | -------------------------------------------------------------------------------- /myhpi/search/templates/search/search_field.html: -------------------------------------------------------------------------------- 1 | {% load bootstrap_icons %} 2 | {% load i18n %} 3 | 4 |
5 |
6 | 11 | 16 |
17 |
18 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0006_auto_20210612_1547.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-06-12 13:47 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("polls", "0005_remove_pollchoice_description"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="poll", 14 | name="max_allowed_answers", 15 | field=models.IntegerField(default=1), 16 | ), 17 | migrations.AddField( 18 | model_name="poll", 19 | name="results_visible", 20 | field=models.BooleanField(default=False), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /myhpi/tenca_django/templates/tenca_django/report.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %} 5 | {% trans "Report Action" %} 6 | {% endblock %} 7 | 8 | {% block breadcrumbs %} 9 | {% if request.user.is_authenticated %} 10 |
  • 11 | {% trans "Mailing Lists" %} 12 |
  • 13 |
  • {% trans "Report Action" %}
  • 14 | {% endif %} 15 | {% endblock %} 16 | 17 | {% block content %} 18 |

    19 | {% trans "Sorry for this inconvenience. Please contact the Student Representative Group if this should happen more often." %} 20 |

    21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /myhpi/tenca_django/migrations/0002_alter_hashentry_options_delete_legacyadminurl.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-15 19:17 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("tenca_django", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="hashentry", 14 | options={ 15 | "verbose_name": "Mailing List Hash Entry", 16 | "verbose_name_plural": "Mailing List Hash Entries", 17 | }, 18 | ), 19 | migrations.DeleteModel( 20 | name="LegacyAdminURL", 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /myhpi/tenca_django/connection.py: -------------------------------------------------------------------------------- 1 | import urllib.error 2 | 3 | import tenca.connection 4 | from django.core.exceptions import ImproperlyConfigured 5 | from mailmanclient.restbase.connection import MailmanConnectionError 6 | 7 | 8 | class FakeConnection: 9 | def __init__(self, exception): 10 | self.exception = exception 11 | 12 | def __getattr__(self, name): 13 | raise self.exception 14 | 15 | 16 | try: 17 | connection = tenca.connection.Connection() 18 | except (MailmanConnectionError, AttributeError) as e: 19 | connection = FakeConnection(ImproperlyConfigured(*e.args)) 20 | except urllib.error.HTTPError as e: 21 | connection = FakeConnection(ImproperlyConfigured(str(e))) 22 | -------------------------------------------------------------------------------- /myhpi/tenca_django/templates/tenca_django/action.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %} 5 | {% trans "Adjust Mailing List Subscription" %} 6 | {% endblock %} 7 | 8 | {% block breadcrumbs %} 9 | {% if request.user.is_authenticated %} 10 | 13 | 14 | {% endif %} 15 | {% endblock %} 16 | 17 | {% block content %} 18 |

    19 | {% trans "This link is now invalid. You never need to visit it again." %} 20 |

    21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0011_alter_taggedminutes_tag.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.9 on 2024-02-19 00:10 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("core", "0010_minutes_location"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="taggedminutes", 15 | name="tag", 16 | field=models.ForeignKey( 17 | on_delete=django.db.models.deletion.CASCADE, 18 | related_name="%(app_label)s_%(class)s_items", 19 | to="core.minuteslabel", 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0004_poll_participants.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-06-12 11:28 2 | 3 | import modelcluster.fields 4 | from django.conf import settings 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ("polls", "0003_auto_20210612_1231"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="poll", 17 | name="participants", 18 | field=modelcluster.fields.ParentalManyToManyField( 19 | related_name="polls", to=settings.AUTH_USER_MODEL 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /myhpi/tenca_django/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from wagtail.admin.panels import FieldPanel, MultiFieldPanel 3 | from wagtail.snippets.models import register_snippet 4 | from wagtail.snippets.views.snippets import SnippetViewSet 5 | 6 | from myhpi.tenca_django.models import HashEntry 7 | 8 | 9 | class HashEntryFilterSet(SnippetViewSet): 10 | model = HashEntry 11 | add_to_admin_menu = True 12 | menu_label = _("Mailing Lists") 13 | menu_icon = "mail" 14 | list_display = ("list_id", "hash_id", "manage_page") 15 | 16 | edit_handler = MultiFieldPanel( 17 | [ 18 | FieldPanel("list_id", read_only=True), 19 | FieldPanel("hash_id"), 20 | ] 21 | ) 22 | 23 | 24 | register_snippet(HashEntryFilterSet) 25 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0003_auto_20210612_1231.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-06-12 10:31 2 | 3 | import django.utils.timezone 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("polls", "0002_auto_20210612_1217"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="poll", 15 | name="end_date", 16 | field=models.DateField(default=django.utils.timezone.now), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name="poll", 21 | name="start_date", 22 | field=models.DateField(default=django.utils.timezone.now), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /myhpi/tenca_django/middleware.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from django.shortcuts import render 4 | 5 | from myhpi.tenca_django.connection import MailmanConnectionError 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class TencaNoConnectionMiddleware: 11 | def __init__(self, get_response): 12 | self.get_response = get_response 13 | 14 | def __call__(self, request): 15 | return self.get_response(request) 16 | 17 | def process_exception(self, request, exception): 18 | if isinstance(exception, MailmanConnectionError): 19 | logger.error( 20 | "An exception occurred when connecting to mailman: " 21 | + " ".join(map(str, exception.args)) 22 | ) 23 | return render(request, "tenca_django/backend_error.html") 24 | else: 25 | return None 26 | -------------------------------------------------------------------------------- /myhpi/static/js/sidebar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sidebar script. Contains: 3 | * 4 | * - Hiding table of contents offcanvas on anchor click 5 | * 6 | */ 7 | 8 | /** 9 | * Initialization 10 | */ 11 | 12 | /** 13 | * Applies necessary JS handlers to sidebar. 14 | * Call when sidebar HTML was loaded. Call handler only once per page. 15 | */ 16 | const initializeSidebar = () => { 17 | applyTocOffcanvasBehaviour() 18 | } 19 | 20 | /** 21 | * Hide ToC offcanvas on anchor click ----------------------------------------------- 22 | */ 23 | 24 | const applyTocOffcanvasBehaviour = () => { 25 | const anchors = document.querySelectorAll("#sidebar-offcanvas a") 26 | const offcanvas = new bootstrap.Offcanvas( 27 | document.querySelector("#sidebar-offcanvas"), 28 | ) 29 | anchors.forEach((anchor) => 30 | anchor.addEventListener("click", () => offcanvas.hide()), 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /myhpi/search/templates/search/search_result.html: -------------------------------------------------------------------------------- 1 | {% load static wagtailcore_tags %} 2 | {% load bootstrap_icons %} 3 | {% load search %} 4 | 5 | 6 |
    7 |
    8 |
    {{ result.title|highlight_title:search_query }}
    9 | 12 |
    13 | 14 | {% if result.search_description %} 15 | {{ result.search_description }} 16 | {% else %} 17 | {{ result.specific.body|highlight_query_markdown:search_query }} 18 | {% endif %} 19 | 20 |
    21 |
    22 | -------------------------------------------------------------------------------- /myhpi/polls/templates/polls/poll_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load wagtailcore_tags %} 4 | 5 | {% block content %} 6 |

    7 | {{ page.title }} 8 |

    9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% for poll in poll_list %} 18 | 19 | 22 | 23 | 24 | {% endfor %} 25 | 26 |
    {% translate "Title" %}{% translate "Voting period" %}
    20 | {{ poll.title }} 21 | {{ poll.start_date }} - {{ poll.end_date }}
    27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /myhpi/static/scss/footer.scss: -------------------------------------------------------------------------------- 1 | .footer-myhpi { 2 | background-color: var(--bs-gray-700); 3 | 4 | .page-content { 5 | padding-top: 1.5rem; 6 | padding-bottom: 0.9rem; 7 | } 8 | } 9 | 10 | .footer-category { 11 | ul { 12 | list-style: none; 13 | padding: 0; 14 | } 15 | 16 | h2 { 17 | font-weight: 700; 18 | font-size: 1rem; 19 | } 20 | 21 | a { 22 | color: var(--bs-text-light); 23 | 24 | &:hover { 25 | text-decoration: underline; 26 | } 27 | } 28 | 29 | .btn-link { 30 | color: var(--bs-text-light); 31 | text-decoration: none; 32 | padding: 0; 33 | } 34 | } 35 | 36 | @media screen and (min-width: 576px) { 37 | .footer-category:not(:last-child) { 38 | margin-right: 5rem; 39 | } 40 | } 41 | 42 | @media print { 43 | .footer-myhpi { 44 | display: none; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /myhpi/static/css/myHPI_admin.css: -------------------------------------------------------------------------------- 1 | .action-set-privacy, 2 | .privacy-indicator { 3 | display: none; 4 | } 5 | 6 | .editor-toolbar.fullscreen, 7 | .CodeMirror-fullscreen { 8 | left: 200px !important ; 9 | bottom: 40px !important; 10 | } 11 | 12 | @media (prefers-color-scheme: dark) { 13 | .select2-container--default 14 | .select2-selection--single 15 | .select2-selection__rendered { 16 | color: white !important; 17 | background-color: inherit !important; 18 | } 19 | 20 | .select2-container--default .select2-selection--single, 21 | .select2-container--default .select2-selection--multiple, 22 | .select2-container--default 23 | .select2-selection--multiple 24 | .select2-selection__choice, 25 | .select2-container--default .select2-results__option[aria-selected="true"] { 26 | background-color: inherit !important; 27 | } 28 | 29 | .select2-dropdown { 30 | background-color: black !important; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0002_auto_20210612_1217.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-06-12 10:17 2 | 3 | import wagtailmarkdown.fields 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("polls", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="poll", 15 | name="description", 16 | field=wagtailmarkdown.fields.MarkdownField(default=""), 17 | preserve_default=False, 18 | ), 19 | migrations.AddField( 20 | model_name="poll", 21 | name="question", 22 | field=models.CharField(default="", max_length=254), 23 | preserve_default=False, 24 | ), 25 | migrations.AlterField( 26 | model_name="pollchoice", 27 | name="text", 28 | field=models.CharField(max_length=254), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-dockerfile 3 | { 4 | "name": "myHPI-dev-container", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "context": ".." 8 | }, 9 | 10 | // Features to add to the dev container. More info: https://containers.dev/features. 11 | // "features": {}, 12 | 13 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 14 | "forwardPorts": [8000], 15 | 16 | // Uncomment the next line to run commands after the container is created. 17 | "postCreateCommand": "bash .devcontainer/setup.sh" 18 | 19 | // Configure tool-specific properties. 20 | // "customizations": {}, 21 | 22 | // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. 23 | // "remoteUser": "devcontainer" 24 | } 25 | -------------------------------------------------------------------------------- /myhpi/tenca_django/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from myhpi.tenca_django import views 4 | 5 | app_name = "tenca_django" 6 | 7 | urlpatterns = [ 8 | path("dashboard/", views.TencaDashboard.as_view(), name="tenca_dashboard"), 9 | path( 10 | "confirm///", views.TencaActionConfirmView.as_view(), name="confirm" 11 | ), 12 | path("report///", views.TencaReportView.as_view(), name="report"), 13 | path("manage//", views.TencaListAdminView.as_view(), name="tenca_manage_list"), 14 | path( 15 | "manage//member/", 16 | views.TencaMemberEditView.as_view(), 17 | name="tenca_edit_member", 18 | ), 19 | path( 20 | "manage//delete/", 21 | views.TencaListDeleteView.as_view(), 22 | name="tenca_delete_list", 23 | ), 24 | path("/", views.TencaSubscriptionView.as_view(), name="tenca_manage_subscription"), 25 | ] 26 | -------------------------------------------------------------------------------- /myhpi/tenca_django/tests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from django.conf import settings 4 | from django.test import TestCase 5 | from tenca.tests import test_hash_storage 6 | 7 | from myhpi.tenca_django.models import DjangoModelHashStorage 8 | 9 | 10 | def skipUnlessListsEnabled(): 11 | if not settings.ENABLE_MAILING_LISTS: 12 | return unittest.skip("Mailing lists not enabled in Django settings") 13 | 14 | return lambda x: x 15 | 16 | 17 | @skipUnlessListsEnabled() 18 | class TencaSettingsLoaded(TestCase): 19 | def setUp(self): 20 | from myhpi.tenca_django.connection import connection 21 | 22 | self.connection = connection 23 | 24 | def testConnectionReceivedSettings(self): 25 | self.assertEqual(settings.TENCA_TEST_LIST_DOMAIN, str(self.connection.domain)) 26 | 27 | 28 | @skipUnlessListsEnabled() 29 | class DjangoModelHashStorageTest(TestCase, test_hash_storage.HiddenFromTestRunner.HashStorageTest): 30 | StorageClass = DjangoModelHashStorage 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/myint/autoflake 3 | rev: v2.3.1 4 | hooks: 5 | - id: autoflake 6 | args: 7 | - --in-place 8 | - --recursive 9 | - --remove-all-unused-imports 10 | - --ignore-init-module-imports 11 | - --expand-star-imports 12 | - repo: https://github.com/PyCQA/isort 13 | rev: 5.13.2 14 | hooks: 15 | - id: isort 16 | - repo: https://github.com/psf/black 17 | rev: 24.4.2 18 | hooks: 19 | - id: black 20 | - repo: https://github.com/djlint/djLint 21 | rev: v1.34.1 22 | hooks: 23 | - id: djlint-reformat-django 24 | args: ["--quiet"] 25 | - id: djlint-django 26 | - repo: local 27 | hooks: 28 | - id: prettier-eslint 29 | name: Prettier and ESLint 30 | entry: prettier-eslint --write --list-different 31 | language: node 32 | types_or: [javascript, css, markdown, yaml] 33 | additional_dependencies: ["prettier-eslint-cli@8.0.1"] 34 | -------------------------------------------------------------------------------- /myhpi/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block title %}Login{% endblock %} 6 | 7 | {% block content %} 8 | {% if user.is_authenticated %} 9 |

    {% translate "Current user:" %} {{ user.email }}

    10 |
    11 | {% csrf_token %} 12 | 13 |
    14 | {% else %} 15 | {% if 'next' in request.GET and not request.GET.user_initiated %} 16 |
    17 | 20 | {% translate "Please login to see this page." %} 21 |
    22 | {% endif %} 23 | Login 24 | {% endif %} 25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0012_abbreviationexplanation_locale_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-11-22 14:42 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("core", "0011_alter_taggedminutes_tag"), 11 | ("wagtailcore", "0094_alter_page_locale"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="abbreviationexplanation", 17 | name="locale", 18 | field=models.ForeignKey( 19 | editable=False, 20 | null=True, 21 | on_delete=django.db.models.deletion.PROTECT, 22 | related_name="+", 23 | to="wagtailcore.locale", 24 | ), 25 | ), 26 | migrations.AddField( 27 | model_name="abbreviationexplanation", 28 | name="translation_key", 29 | field=models.UUIDField(editable=False, null=True), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /myhpi/tenca_django/mixins.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import AccessMixin 2 | from django.http import Http404 3 | 4 | from myhpi.core.utils import alternative_emails 5 | from myhpi.tenca_django.connection import connection 6 | 7 | 8 | class TencaSingleListMixin: 9 | def setup(self, request, *args, **kwargs): 10 | super().setup(request, *args, **kwargs) 11 | self.mailing_list = connection.get_list(kwargs["list_id"]) 12 | if not self.mailing_list: 13 | raise Http404 14 | 15 | 16 | class TencaListAdminMixin(AccessMixin, TencaSingleListMixin): 17 | def dispatch(self, request, *args, **kwargs): 18 | if not ( 19 | request.user.is_staff 20 | or self.mailing_list.is_owner(request.user.email) 21 | or any( 22 | self.mailing_list.is_owner(email) 23 | for email in alternative_emails(request.user.email) 24 | ) 25 | ): 26 | return self.handle_no_permission() 27 | return super().dispatch(request, *args, **kwargs) 28 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_widgets.py: -------------------------------------------------------------------------------- 1 | from django.forms.models import ModelChoiceIteratorValue 2 | 3 | from myhpi.core.widgets import AttachmentSelectWidget 4 | from myhpi.tests.core.utils import MyHPIPageTestCase 5 | 6 | 7 | class WidgetTests(MyHPIPageTestCase): 8 | def setUp(self): 9 | super().setUp() 10 | 11 | def test_attachment_select_widget(self): 12 | self.sign_in_as_student() 13 | # student has access to document 1, not document 2 14 | choices = list( 15 | map( 16 | lambda doc: (ModelChoiceIteratorValue(doc.id, doc), doc.title), 17 | self.test_data["documents"], 18 | ) 19 | ) 20 | widget = AttachmentSelectWidget(user=self.student, choices=choices) 21 | optgroups = widget.optgroups("attachments", []) 22 | self.assertEqual(len(optgroups), 1) 23 | 24 | widget = AttachmentSelectWidget(user=self.student_representative, choices=choices) 25 | optgroups = widget.optgroups("attachments", []) 26 | self.assertEqual(len(optgroups), 2) 27 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0013_assign_translation_keys.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import migrations 4 | 5 | 6 | def assign_translation_keys(apps, schema_editor): 7 | MajorityVoteChoice = apps.get_model("polls", "MajorityVoteChoice") 8 | RankedChoiceOption = apps.get_model("polls", "RankedChoiceOption") 9 | Locale = apps.get_model("wagtailcore", "Locale") 10 | default_locale = Locale.objects.first() 11 | for obj in MajorityVoteChoice.objects.all(): 12 | obj.translation_key = uuid.uuid4() 13 | obj.locale = default_locale 14 | obj.save(update_fields=["translation_key", "locale"]) 15 | for obj in RankedChoiceOption.objects.all(): 16 | obj.translation_key = uuid.uuid4() 17 | obj.locale = default_locale 18 | obj.save(update_fields=["translation_key", "locale"]) 19 | 20 | 21 | class Migration(migrations.Migration): 22 | dependencies = [ 23 | ("polls", "0012_alter_majorityvotechoice_options_and_more"), 24 | ] 25 | operations = [ 26 | migrations.RunPython(assign_translation_keys, reverse_code=migrations.RunPython.noop), 27 | ] 28 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0008_polllist.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-06-12 14:44 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("wagtailcore", "0060_fix_workflow_unique_constraint"), 10 | ("polls", "0007_auto_20210612_1620"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="PollList", 16 | fields=[ 17 | ( 18 | "basepage_ptr", 19 | models.OneToOneField( 20 | auto_created=True, 21 | on_delete=django.db.models.deletion.CASCADE, 22 | parent_link=True, 23 | primary_key=True, 24 | serialize=False, 25 | to="core.basepage", 26 | ), 27 | ), 28 | ], 29 | options={ 30 | "abstract": False, 31 | }, 32 | bases=("core.basepage",), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_redirect_menu_item.py: -------------------------------------------------------------------------------- 1 | from myhpi.core.models import RedirectMenuItem, RootPage 2 | from myhpi.tests.core.utils import MyHPIPageTestCase 3 | 4 | 5 | class RedirectMenuItemTests(MyHPIPageTestCase): 6 | def create_redirect_menu_item(self, name, redirect_url): 7 | root_page = RootPage.objects.get(slug="myhpi") 8 | redirect_menu_item = RedirectMenuItem( 9 | title=name, redirect_url=redirect_url, slug=name, is_public=True 10 | ) 11 | root_page.add_child(instance=redirect_menu_item) 12 | return redirect_menu_item 13 | 14 | def setUp(self): 15 | super().setUp() 16 | self.redirect_menu_item = self.create_redirect_menu_item("example", "https://example.com") 17 | 18 | def test_redirect_menu_item_redirects(self): 19 | redirection = self.client.get("/en/example/") 20 | self.assertEqual(redirection.status_code, 302) 21 | 22 | def test_redirect_menu_item_redirects_to_page(self): 23 | redirection = self.client.get("/en/example/", follow=True) 24 | self.assertEqual(redirection.redirect_chain[0][0], "https://example.com") 25 | -------------------------------------------------------------------------------- /myhpi/core/templates/core/toc_button.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% load core_extras %} 4 | {% load i18n %} 5 | {% load bootstrap_icons %} 6 | 7 | {% if toc|hasTocContent or attachments %} 8 | 25 | {% endif %} 26 | -------------------------------------------------------------------------------- /myhpi/static/js/search.js: -------------------------------------------------------------------------------- 1 | const initializeSearch = () => { 2 | const searchModal = document.querySelector("#searchModal") 3 | 4 | /** 5 | * Focus input in search modal when showing. 6 | * We use both events: 7 | * - `show.bs.modal` focuses immediately (with a short delay), so the user can start typing before the popup is completely visible 8 | * - `shown.bs.modal` refocuses the input once the popup is completely visible. Without, the input would loose its focus at that point 9 | */ 10 | searchModal.addEventListener("show.bs.modal", () => 11 | setTimeout(() => document.querySelector("#searchInput").focus(), 200), 12 | ) 13 | searchModal.addEventListener("shown.bs.modal", () => 14 | document.querySelector("#searchInput").focus(), 15 | ) 16 | 17 | document.addEventListener("keydown", (event) => { 18 | if (event.ctrlKey && event.key === "k") { 19 | showSearchModal() 20 | event.preventDefault() 21 | } 22 | }) 23 | } 24 | 25 | const showSearchModal = () => { 26 | const searchModal = document.querySelector("#searchModal") 27 | bootstrap.Modal.getOrCreateInstance(searchModal).show() 28 | } 29 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SECRET_KEY=&lmx#ngmhed!#t$olcee^h@x3ixta=&gux+2u+fqqtb2l4mge( 2 | DEBUG=True 3 | DJANGO_DEBUG=False 4 | ALLOWED_HOSTS="*" 5 | STATIC_URL=/static/ 6 | STATIC_ROOT=data/static/ 7 | MEDIA_URL=/media/ 8 | MEDIA_ROOT=data/media/ 9 | INTERNAL_IPS=["127.0.0.1","localhost"] 10 | SITE_URL=http://localhost:8000 11 | EMAIL_URL=dummymail:// 12 | DEFAULT_FROM_EMAIL=webmaster@localhost 13 | SERVER_EMAIL=root@localhost 14 | ADMINS=Root User 15 | OIDC_RP_CLIENT_ID=id 16 | OIDC_RP_CLIENT_SECRET=secret 17 | POSTGRES_USER=myhpi 18 | POSTGRES_PASSWORD=myhpi 19 | POSTGRES_HOST=postgres 20 | POSTGRES_PORT=5432 21 | DEEPL_API_KEY=secret 22 | 23 | ENABLE_MAILING_LISTS=False 24 | TENCA_API_HOST=lists.myhpi.de 25 | TENCA_API_PORT=8425 26 | TENCA_API_SCHEME=https 27 | TENCA_ADMIN_USER=test 28 | TENCA_ADMIN_PASS=test 29 | TENCA_LIST_HASH_ID_SALT=salt_here 30 | TENCA_WEB_UI_HOSTNAME=lists.myhpi.de 31 | TENCA_DISABLE_GOODBYE_MESSAGES=True 32 | TENCA_HASH_STORAGE_CLASS=myhpi.tenca_django.models.DjangoModelCachedDescriptionHashStorage 33 | TENCA_TEST_LIST_DOMAIN=TENCA_WEB_UI_HOSTNAME 34 | 35 | ANONYMOUS_IP_RANGE_GROUPS="127.0.0.1/32=Moderators,127.0.0.0/8=localhost" 36 | -------------------------------------------------------------------------------- /.github/workflows/build-container.yml: -------------------------------------------------------------------------------- 1 | name: build-container 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | tags: 8 | - "v*" 9 | 10 | jobs: 11 | docker: 12 | runs-on: ubuntu-latest 13 | permissions: 14 | packages: write 15 | contents: read 16 | if: ${{ github.triggering_actor != 'dependabot[bot]' }} 17 | steps: 18 | - name: Checkout # Checkout the repository to allow for dynamic versioning 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | 23 | - name: Docker meta 24 | id: meta 25 | uses: docker/metadata-action@v5 26 | with: 27 | images: ghcr.io/fsr-de/myhpi 28 | 29 | - name: Login to DockerHub 30 | uses: docker/login-action@v2 31 | with: 32 | registry: ghcr.io 33 | username: ${{ github.actor }} 34 | password: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Build and push 37 | uses: docker/build-push-action@v3 38 | with: 39 | context: . 40 | push: true 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0007_alter_footer_column_1_alter_footer_column_2_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-12-17 09:06 2 | 3 | from django.db import migrations 4 | 5 | import myhpi.core.markdown.fields 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("core", "0006_informationpage_attachments_minutes_attachments_and_more"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="footer", 16 | name="column_1", 17 | field=myhpi.core.markdown.fields.CustomMarkdownField(), 18 | ), 19 | migrations.AlterField( 20 | model_name="footer", 21 | name="column_2", 22 | field=myhpi.core.markdown.fields.CustomMarkdownField(), 23 | ), 24 | migrations.AlterField( 25 | model_name="footer", 26 | name="column_3", 27 | field=myhpi.core.markdown.fields.CustomMarkdownField(), 28 | ), 29 | migrations.AlterField( 30 | model_name="footer", 31 | name="column_4", 32 | field=myhpi.core.markdown.fields.CustomMarkdownField(), 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /myhpi/polls/templatetags/polls.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from wagtail.models import Locale 3 | 4 | register = template.Library() 5 | 6 | 7 | @register.filter(name="can_vote") 8 | def can_vote(poll, request): 9 | return poll.can_vote(request.user, request, allow_preview=True) 10 | 11 | 12 | @register.filter 13 | def get_localized_choice(canonical_choice, request): 14 | locale_code = request.LANGUAGE_CODE 15 | locale = Locale.objects.get(language_code=locale_code) 16 | localized_choice = canonical_choice.get_translation_or_none(locale) 17 | if localized_choice: 18 | return localized_choice 19 | else: 20 | return canonical_choice 21 | 22 | 23 | # Ranked choice poll localization utilities 24 | 25 | 26 | @register.filter 27 | def get_localized_form(canonical_poll, request): 28 | locale_code = request.LANGUAGE_CODE 29 | locale = Locale.objects.get(language_code=locale_code) 30 | return canonical_poll.get_ballot_form(locale=locale) 31 | 32 | 33 | @register.filter 34 | def get_localized_results(canonical_poll, request): 35 | locale_code = request.LANGUAGE_CODE 36 | locale = Locale.objects.get(language_code=locale_code) 37 | return canonical_poll.calculate_ranking(locale=locale) 38 | -------------------------------------------------------------------------------- /myhpi/core/templates/core/text_array_widget.html: -------------------------------------------------------------------------------- 1 | 5 |
    6 | {% if strings|length == 0 %} 7 |
    8 | 11 | X 12 |
    13 | {% endif %} 14 | {% for string in strings %} 15 |
    16 | 20 | X 21 |
    22 | {% endfor %} 23 |
    24 | 28 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0014_alter_abbreviationexplanation_locale_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2024-11-22 14:45 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("core", "0013_translate_abbreviationexplanations"), 13 | ("wagtailcore", "0094_alter_page_locale"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterField( 18 | model_name="abbreviationexplanation", 19 | name="locale", 20 | field=models.ForeignKey( 21 | editable=False, 22 | on_delete=django.db.models.deletion.PROTECT, 23 | related_name="+", 24 | to="wagtailcore.locale", 25 | verbose_name="locale", 26 | ), 27 | ), 28 | migrations.AlterField( 29 | model_name="abbreviationexplanation", 30 | name="translation_key", 31 | field=models.UUIDField(default=uuid.uuid4, editable=False), 32 | ), 33 | migrations.AlterUniqueTogether( 34 | name="abbreviationexplanation", 35 | unique_together={("translation_key", "locale")}, 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /myhpi/polls/forms.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import ValidationError 2 | from django.forms import ChoiceField, Form 3 | 4 | 5 | class RankedChoiceBallotForm(Form): 6 | def __init__(self, *args, options=None, **kwargs): 7 | super().__init__(*args, **kwargs) 8 | self.options = options 9 | for option in self.options: 10 | self.fields[f"option_{option.pk}"] = ChoiceField( 11 | choices=[("unranked", "unranked")] 12 | + [(i, i) for i in range(1, len(self.options) + 1)], 13 | label=option.name, 14 | help_text=option.description, 15 | ) 16 | 17 | def clean(self): 18 | cleaned_data = super().clean() 19 | unranked = [] 20 | ranks = [] 21 | for key, rank in cleaned_data.items(): 22 | if rank == "unranked": 23 | unranked.append(key) 24 | elif int(rank) not in range(1, len(self.options) + 1): 25 | raise ValidationError("Invalid rank.") 26 | else: 27 | ranks.append(rank) 28 | if len(ranks) != len(set(ranks)): 29 | raise ValidationError("All ranks must be unique.") 30 | for key in unranked: 31 | del cleaned_data[key] 32 | return cleaned_data 33 | -------------------------------------------------------------------------------- /myhpi/core/templatetags/minutes_tags.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.simple_tag(takes_context=True) 7 | def next_minutes(context, minutes): 8 | # This may be None if there is no next minutes. 9 | try: 10 | return ( 11 | minutes.get_parent() 12 | .specific.get_visible_minutes(context.request) 13 | .exclude(basepage_ptr_id__lte=minutes.basepage_ptr_id, date=minutes.date) 14 | .filter(date__gte=minutes.date) 15 | .order_by("date", "basepage_ptr_id") 16 | ).first() 17 | # A value error is raised for previewing minutes (#427) 18 | except ValueError: 19 | return None 20 | 21 | 22 | @register.simple_tag(takes_context=True) 23 | def prev_minutes(context, minutes): 24 | # This may be None if there is no previous minutes. 25 | try: 26 | return ( 27 | minutes.get_parent() 28 | .specific.get_visible_minutes(context.request) 29 | .exclude(basepage_ptr_id__gte=minutes.basepage_ptr_id, date=minutes.date) 30 | .filter(date__lte=minutes.date) 31 | .order_by("-date", "-basepage_ptr_id") 32 | ).first() 33 | # A value error is raised for previewing minutes (#427) 34 | except ValueError: 35 | return None 36 | -------------------------------------------------------------------------------- /myhpi/templates/footer.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load core_extras %} 3 | 4 |
    5 |
    6 | {% for column in footer_columns %} 7 | 12 | {% endfor %} 13 | 28 |
    29 |
    30 | -------------------------------------------------------------------------------- /myhpi/tenca_django/forms.py: -------------------------------------------------------------------------------- 1 | from django.forms import BooleanField, CharField, EmailField, EmailInput, Form 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | 5 | class TencaSubscriptionForm(Form): 6 | email = EmailField() 7 | 8 | 9 | class TencaNewListForm(Form): 10 | list_name = CharField() 11 | 12 | 13 | class TencaListOptionsForm(Form): 14 | notsubscribed_allowed_to_post = BooleanField( 15 | label=_("Not subscribed users are allowed to post"), required=False 16 | ) 17 | replies_addressed_to_list = BooleanField( 18 | label=_("Replies are addressed to the list per default"), required=False 19 | ) 20 | footer_has_subscribe_link = BooleanField( 21 | label=_("Footer contains invitation link"), required=False 22 | ) 23 | # Fields should be named according to their respective setting on the tenca list object 24 | 25 | def __init__(self, *args, **kwargs): 26 | mailing_list = kwargs.pop("mailing_list") 27 | super().__init__(*args, **kwargs) 28 | for key, field in self.fields.items(): 29 | field.initial = getattr(mailing_list, key) 30 | self.label_suffix = "" 31 | 32 | 33 | class TencaMemberEditForm(Form): 34 | email = EmailField( 35 | widget=EmailInput(attrs={"class": "form-control-plaintext", "readonly": True}) 36 | ) 37 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0006_informationpage_attachments_minutes_attachments_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-30 12:17 2 | 3 | import modelcluster.fields 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("wagtaildocs", "0012_uploadeddocument"), 10 | ("core", "0005_redirectmenuitem"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="informationpage", 16 | name="attachments", 17 | field=modelcluster.fields.ParentalManyToManyField( 18 | blank=True, to="wagtaildocs.document" 19 | ), 20 | ), 21 | migrations.AddField( 22 | model_name="minutes", 23 | name="attachments", 24 | field=modelcluster.fields.ParentalManyToManyField( 25 | blank=True, to="wagtaildocs.document" 26 | ), 27 | ), 28 | migrations.AlterField( 29 | model_name="redirectmenuitem", 30 | name="redirect_url", 31 | field=models.CharField( 32 | help_text="The URL that the user should be redirected to when selecting this menu item", 33 | max_length=255, 34 | verbose_name="redirect URL", 35 | ), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /myhpi/core/middleware.py: -------------------------------------------------------------------------------- 1 | from ipaddress import ip_address, ip_network 2 | 3 | from django.conf import settings 4 | from django.core.exceptions import ImproperlyConfigured 5 | 6 | 7 | def get_client_ip(request): 8 | x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") 9 | if x_forwarded_for: 10 | ip = x_forwarded_for.split(",")[0] 11 | else: 12 | ip = request.META.get("REMOTE_ADDR") 13 | return ip 14 | 15 | 16 | class IPRangeUserMiddleware: 17 | def __init__(self, get_response): 18 | self.get_response = get_response 19 | try: 20 | self.ip_ranges = { 21 | ip_network(k): v for k, v in settings.ANONYMOUS_IP_RANGE_GROUPS.items() 22 | } 23 | except ValueError as e: 24 | raise ImproperlyConfigured from e 25 | 26 | def __call__(self, request): 27 | self.process_request(request) 28 | response = self.get_response(request) 29 | return response 30 | 31 | def process_request(self, request): 32 | address = ip_address(get_client_ip(request)) 33 | request.user.ip_range_group_name = [] 34 | for ip_range, group_name in self.ip_ranges.items(): 35 | if address in ip_range: 36 | # user is in this IP range 37 | request.user.ip_range_group_name = group_name 38 | break 39 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_minute_lists.py: -------------------------------------------------------------------------------- 1 | from myhpi.tests.core.utils import MyHPIPageTestCase 2 | 3 | 4 | class MinutesListTests(MyHPIPageTestCase): 5 | def test_students_can_view_some_minutes_in_minutes_list(self): 6 | self.sign_in_as_student() 7 | minutes_list = self.client.get("/en/student-representation/fsr/minutes", follow=True) 8 | self.assertInHTML("First minutes", minutes_list.rendered_content) 9 | self.assertInHTML("Second minutes", minutes_list.rendered_content) 10 | self.assertNotIn("Private minutes", minutes_list.rendered_content) 11 | self.assertNotIn("Unpublished minutes", minutes_list.rendered_content) 12 | self.assertInHTML("Recent minutes", minutes_list.rendered_content) 13 | 14 | def test_student_representatives_can_view_minutes_in_minutes_list(self): 15 | self.sign_in_as_student_representative() 16 | minutes_list = self.client.get("/en/student-representation/fsr/minutes", follow=True) 17 | self.assertInHTML("First minutes", minutes_list.rendered_content) 18 | self.assertInHTML("Second minutes", minutes_list.rendered_content) 19 | self.assertInHTML("Private minutes", minutes_list.rendered_content) 20 | self.assertInHTML("Unpublished minutes", minutes_list.rendered_content) 21 | self.assertInHTML("Recent minutes", minutes_list.rendered_content) 22 | -------------------------------------------------------------------------------- /myhpi/templates/nav_level.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load wagtailcore_tags %} 3 | {% load core_extras %} 4 | {% load bootstrap_icons %} 5 | 6 | 29 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0005_redirectmenuitem.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-28 21:44 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("core", "0004_minuteslabel_color"), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="RedirectMenuItem", 15 | fields=[ 16 | ( 17 | "basepage_ptr", 18 | models.OneToOneField( 19 | auto_created=True, 20 | on_delete=django.db.models.deletion.CASCADE, 21 | parent_link=True, 22 | primary_key=True, 23 | serialize=False, 24 | to="core.basepage", 25 | ), 26 | ), 27 | ( 28 | "redirect_url", 29 | models.CharField( 30 | help_text="The URL that the user should redirected to when selecting this menu item", 31 | max_length=255, 32 | verbose_name="redirect URL", 33 | ), 34 | ), 35 | ], 36 | options={ 37 | "abstract": False, 38 | }, 39 | bases=("core.basepage",), 40 | ), 41 | ] 42 | -------------------------------------------------------------------------------- /myhpi/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load bootstrap_icons %} 4 | 5 | {% block title %} 6 | {% translate "Access denied" %} 7 | {% endblock %} 8 | 9 | {% block body_class %}template-403{% endblock %} 10 | 11 | {% block content %} 12 |

    {% translate "Access denied" %}

    13 | 14 |

    15 | {% translate "Unfortunately you are not allowed to view this page. This might have one of the following reasons:" %} 16 |

    17 | 18 |
      19 |
    • 20 | {% translate "You are not accessing the page from the university network." %} 21 |
    • 22 | {% if not user.is_authenticated %} 23 |
    • 24 | {% translate "You are not logged in. The page may only be available for logged in users." %} 25 |
    • 26 |
      27 | 30 | {% translate "Sign in" %} 31 | {% bs_icon 'box-arrow-in-right' size='1.4em' %} 32 | 33 | {% else %} 34 |
    • 35 | {% translate "You don't have the required permissions to access this page. Consider contacting the source of the link you were given." %} 36 |
    • 37 | {% endif %} 38 |
    39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /myhpi/core/markdown/fields.py: -------------------------------------------------------------------------------- 1 | import html2text 2 | from wagtail_localize.segments import ( 3 | OverridableSegmentValue, 4 | StringSegmentValue, 5 | TemplateSegmentValue, 6 | ) 7 | from wagtail_localize.segments.extract import quote_path_component 8 | from wagtail_localize.segments.ingest import organise_template_segments 9 | from wagtail_localize.strings import extract_strings, restore_strings 10 | from wagtailmarkdown.fields import MarkdownField 11 | from wagtailmarkdown.utils import render_markdown 12 | 13 | 14 | class CustomMarkdownField(MarkdownField): 15 | def get_translatable_segments(self, value): 16 | template, strings = extract_strings(render_markdown(value)) 17 | 18 | # Find all unique href values 19 | hrefs = set() 20 | for string, attrs in strings: 21 | for tag_attrs in attrs.values(): 22 | if "href" in tag_attrs: 23 | hrefs.add(tag_attrs["href"]) 24 | 25 | return ( 26 | [TemplateSegmentValue("", "html", template, len(strings))] 27 | + [StringSegmentValue("", string, attrs=attrs) for string, attrs in strings] 28 | + [OverridableSegmentValue(quote_path_component(href), href) for href in sorted(hrefs)] 29 | ) 30 | 31 | def restore_translated_segments(self, value, field_segments): 32 | format, template, strings = organise_template_segments(field_segments) 33 | return html2text.html2text(restore_strings(template, strings), bodywidth=0) 34 | -------------------------------------------------------------------------------- /myhpi/core/markdown/utils.py: -------------------------------------------------------------------------------- 1 | import markdown 2 | from django.utils.encoding import smart_str 3 | from django.utils.safestring import mark_safe 4 | from wagtail.models import Locale 5 | from wagtailmarkdown.utils import _get_markdown_kwargs, _sanitise_markdown_html 6 | 7 | from myhpi.core.markdown.extensions import MinuteExtension 8 | 9 | 10 | def render_markdown(text, context=None, with_abbreveations=True): 11 | """ 12 | Turn markdown into HTML. 13 | """ 14 | if context is None or not isinstance(context, dict): 15 | context = {} 16 | markdown_html, toc = _transform_markdown_into_html(text, with_abbreviations=with_abbreveations) 17 | sanitised_markdown_html = _sanitise_markdown_html(markdown_html) 18 | return mark_safe(sanitised_markdown_html), mark_safe(toc) 19 | 20 | 21 | def _transform_markdown_into_html(text, with_abbreviations): 22 | from myhpi.core.models import AbbreviationExplanation 23 | 24 | markdown_kwargs = _get_markdown_kwargs() 25 | markdown_kwargs["extensions"].append( 26 | MinuteExtension() 27 | ) # should be in settings.py, but module lookup doesn't work 28 | md = markdown.Markdown(**markdown_kwargs) 29 | abbreviations = "\n\n" + ( 30 | "\n".join( 31 | [ 32 | f"*[{abbr.abbreviation}]: {abbr.explanation}" 33 | for abbr in AbbreviationExplanation.objects.filter(locale=Locale.get_active()) 34 | ] 35 | ) 36 | ) 37 | text = smart_str(text) + abbreviations if with_abbreviations else smart_str(text) 38 | return md.convert(text), md.toc 39 | -------------------------------------------------------------------------------- /myhpi/core/wagtail_hooks.py: -------------------------------------------------------------------------------- 1 | from itertools import chain 2 | 3 | from django.core.exceptions import PermissionDenied 4 | from django.templatetags.static import static 5 | from django.utils.html import format_html 6 | from wagtail import hooks 7 | 8 | from myhpi.core.models import InformationPage, Minutes, MinutesList 9 | 10 | 11 | @hooks.register("before_serve_page") 12 | def check_view_permissions(page, request, serve_args, serve_kwargs): 13 | if isinstance(page, (Minutes, MinutesList, InformationPage)): 14 | page.specific.check_can_view(request) 15 | 16 | 17 | @hooks.register("before_serve_document") 18 | def check_document_permissions(document, request): 19 | can_view = False 20 | for page in chain(document.informationpage_set.all(), document.minutes_set.all()): 21 | try: 22 | check_view_permissions(page, request, (), {}) 23 | can_view = True 24 | break 25 | except PermissionDenied: 26 | continue 27 | if not can_view: 28 | raise PermissionDenied 29 | 30 | 31 | @hooks.register("insert_global_admin_css") 32 | def global_admin_css(): 33 | return format_html('', static("css/myHPI_admin.css")) 34 | 35 | 36 | @hooks.register("insert_global_admin_js", order=100) 37 | def global_admin_js(): 38 | return format_html( 39 | '', 40 | static("js/admin/easymde_custom.js"), 41 | static("wagtailimages/js/image-chooser-modal.js"), 42 | static("wagtailadmin/js/page-chooser-modal.js"), 43 | ) 44 | -------------------------------------------------------------------------------- /myhpi/tenca_django/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.13 on 2021-03-13 16:08 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="HashEntry", 15 | fields=[ 16 | ( 17 | "id", 18 | models.AutoField( 19 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 20 | ), 21 | ), 22 | ("hash_id", models.CharField(max_length=64, unique=True)), 23 | ("list_id", models.CharField(max_length=128)), 24 | ], 25 | ), 26 | migrations.CreateModel( 27 | name="LegacyAdminURL", 28 | fields=[ 29 | ( 30 | "id", 31 | models.AutoField( 32 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 33 | ), 34 | ), 35 | ("admin_url", models.CharField(max_length=32)), 36 | ( 37 | "hash_id", 38 | models.ForeignKey( 39 | on_delete=django.db.models.deletion.CASCADE, 40 | related_name="legacy_admin_url", 41 | to="tenca_django.HashEntry", 42 | ), 43 | ), 44 | ], 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /myhpi/search/views.py: -------------------------------------------------------------------------------- 1 | from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator 2 | from django.db.models import Q 3 | from django.template.response import TemplateResponse 4 | from wagtail.contrib.search_promotions.models import Query 5 | from wagtail.models import Page 6 | 7 | from myhpi.core.models import BasePage 8 | from myhpi.core.utils import get_user_groups 9 | 10 | 11 | def search(request): 12 | search_query = request.GET.get("query", None) 13 | page = request.GET.get("page", 1) 14 | 15 | # Search 16 | if search_query: 17 | user_groups = get_user_groups(request.user) 18 | allowed_pages = ( 19 | BasePage.objects.live() 20 | .filter(Q(visible_for__in=user_groups) | Q(is_public=True)) 21 | .distinct() 22 | .order_by("-last_published_at") 23 | ) 24 | search_results = allowed_pages.search(search_query) 25 | query = Query.get(search_query) 26 | 27 | # Record hit 28 | query.add_hit() 29 | else: 30 | search_results = Page.objects.none() 31 | 32 | # Pagination 33 | paginator = Paginator(search_results, 20) 34 | try: 35 | search_results_page = paginator.page(page) 36 | except PageNotAnInteger: 37 | search_results_page = paginator.page(1) 38 | except EmptyPage: 39 | search_results_page = paginator.page(paginator.num_pages) 40 | 41 | return TemplateResponse( 42 | request, 43 | "search/search.html", 44 | { 45 | "search_query": search_query, 46 | "search_results_page": search_results_page, 47 | }, 48 | ) 49 | -------------------------------------------------------------------------------- /myhpi/tenca_django/templates/tenca_django/delete_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %} 5 | {% trans "Delete Mailing List" %} 6 | {% endblock %} 7 | 8 | {% block breadcrumbs %} 9 | 12 | 13 | {% endblock %} 14 | 15 | {% block content %} 16 |

    17 | {% trans "Delete Mailing List" %} 18 |

    19 | 20 |
    21 |
    22 | {% trans "List" %}: {{ listname }} 23 |
    24 |
    25 | 26 |
    27 |
    28 |

    29 | {% blocktrans trimmed %} 30 | Are you sure you want to delete the list {{ listname }}? 31 | {% endblocktrans %} 32 |

    33 |
    34 | {% csrf_token %} 35 |
    36 | 39 | {% trans "Cancel" %} 41 |
    42 |
    43 |
    44 |
    45 | 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /myhpi/core/utils.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group 2 | from django.db.models import Q 3 | 4 | from myhpi import settings 5 | 6 | 7 | def get_user_groups(user): 8 | if getattr(user, "ip_range_group_name", False): 9 | # join user groups together with the groups they have based on their IP address 10 | return Group.objects.filter(Q(name=user.ip_range_group_name) | Q(id__in=user.groups.all())) 11 | else: 12 | # use the users groups only 13 | return user.groups.all() 14 | 15 | 16 | # taken from https://github.com/fsr-de/1327/blob/master/_1327/main/utils.py 17 | def email_belongs_to_domain(email, domain): 18 | return email.rpartition("@")[2] == domain 19 | 20 | 21 | def replace_email_domain(email, original_domain, new_domain): 22 | return email[: -len(original_domain)] + new_domain 23 | 24 | 25 | def toggle_institution(email): 26 | for original_domain, new_domain in settings.INSTITUTION_EMAIL_REPLACEMENTS: 27 | if email_belongs_to_domain(email, original_domain): 28 | yield replace_email_domain(email, original_domain, new_domain) 29 | elif email_belongs_to_domain(email, new_domain): 30 | yield replace_email_domain(email, new_domain, original_domain) 31 | 32 | 33 | def alternative_emails(email): 34 | yield from toggle_institution(email) 35 | for current_domain, alumni_domain in settings.ALUMNI_EMAIL_REPLACEMENTS: 36 | if email_belongs_to_domain(email, current_domain): 37 | alumni_mail = replace_email_domain(email, current_domain, alumni_domain) 38 | yield alumni_mail 39 | yield from toggle_institution(alumni_mail) 40 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_email_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase 2 | 3 | from myhpi.core.utils import ( 4 | alternative_emails, 5 | email_belongs_to_domain, 6 | replace_email_domain, 7 | toggle_institution, 8 | ) 9 | 10 | 11 | class EmailUtilTest(TestCase): 12 | def test_email_belongs_to_domain(self): 13 | emails = ["abc@example.com", "abc@myhpi.de"] 14 | domains = ["example.com", "myhpi.de"] 15 | for email, domain in zip(emails, domains): 16 | self.assertTrue(email_belongs_to_domain(email, domain)) 17 | self.assertFalse(email_belongs_to_domain(emails[0], domains[1])) 18 | 19 | def test_replace_email_domain(self): 20 | email = "abc@example.com" 21 | original_domain = "example.com" 22 | new_domain = "myhpi.de" 23 | self.assertEqual(replace_email_domain(email, original_domain, new_domain), "abc@myhpi.de") 24 | 25 | def test_toggle_institution(self): 26 | emails = ["user1@hpi.uni-potsdam.de", "user2@unrelated.com", "user3@hpi.de"] 27 | expected = ["user1@hpi.de", "user2@unrelated.com", "user3@hpi.uni-potsdam.de"] 28 | for email, expected_email in zip(emails, expected): 29 | toggled = list(toggle_institution(email)) 30 | if not "unrelated" in email: 31 | self.assertEqual(toggled[0], expected_email) 32 | 33 | def test_alternative_emails(self): 34 | email = "user@hpi.de" 35 | alternatives = [ 36 | "user@hpi.uni-potsdam.de", 37 | "user@student.hpi.de", 38 | "user@student.hpi.uni-potsdam.de", 39 | ] 40 | self.assertSetEqual(set(alternative_emails(email)), set(alternatives)) 41 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0003_minutes_guests_alter_informationpage_body_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0.7 on 2022-09-15 20:55 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | import myhpi.core.markdown.fields 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ("core", "0002_rootpage"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AddField( 18 | model_name="minutes", 19 | name="guests", 20 | field=models.JSONField(blank=True, default=[]), 21 | ), 22 | migrations.AlterField( 23 | model_name="informationpage", 24 | name="body", 25 | field=myhpi.core.markdown.fields.CustomMarkdownField(), 26 | ), 27 | migrations.AlterField( 28 | model_name="minutes", 29 | name="author", 30 | field=models.ForeignKey( 31 | blank=True, 32 | null=True, 33 | on_delete=django.db.models.deletion.PROTECT, 34 | related_name="author", 35 | to=settings.AUTH_USER_MODEL, 36 | ), 37 | ), 38 | migrations.AlterField( 39 | model_name="minutes", 40 | name="moderator", 41 | field=models.ForeignKey( 42 | blank=True, 43 | null=True, 44 | on_delete=django.db.models.deletion.PROTECT, 45 | related_name="moderator", 46 | to=settings.AUTH_USER_MODEL, 47 | ), 48 | ), 49 | migrations.AlterField( 50 | model_name="minutes", 51 | name="body", 52 | field=myhpi.core.markdown.fields.CustomMarkdownField(), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_search.py: -------------------------------------------------------------------------------- 1 | from myhpi.tests.core.utils import MyHPIPageTestCase 2 | 3 | 4 | class SearchTests(MyHPIPageTestCase): 5 | def test_search_page_exists(self): 6 | search_page = self.client.get("/en/search/", follow=True) 7 | self.assertEqual(search_page.status_code, 200) 8 | 9 | def test_can_find_public_page(self): 10 | search_page = self.client.get("/en/search/", data={"query": "Page"}, follow=True) 11 | self.assertInHTML("Public Page", search_page.rendered_content) 12 | 13 | def test_unauthorized_user_can_not_find_pages(self): 14 | search_page = self.client.get("/en/search/", data={"query": "Page"}, follow=True) 15 | self.assertNotIn("Common Page", search_page.rendered_content) 16 | self.assertNotIn("Private Page", search_page.rendered_content) 17 | 18 | def test_user_in_group_can_find_group_restricted_pages(self): 19 | self.sign_in_as_student() 20 | search_page = self.client.get("/en/search/", data={"query": "Page"}, follow=True) 21 | self.assertInHTML("Public Page", search_page.rendered_content) 22 | self.assertInHTML("Common Page", search_page.rendered_content) 23 | 24 | def test_user_can_not_find_pages_only_for_other_groups(self): 25 | self.sign_in_as_student() 26 | search_page = self.client.get("/en/search/", data={"query": "Page"}, follow=True) 27 | self.assertNotIn("Private Page", search_page.rendered_content) 28 | 29 | def test_user_in_group_can_find_multiple_group_restricted_pages(self): 30 | self.sign_in_as_student_representative() 31 | search_page = self.client.get("/en/search/", data={"query": "Page"}, follow=True) 32 | self.assertInHTML("Public Page", search_page.rendered_content) 33 | self.assertInHTML("Common Page", search_page.rendered_content) 34 | self.assertInHTML("Private Page", search_page.rendered_content) 35 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0012_alter_majorityvotechoice_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.0.8 on 2025-07-23 19:20 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ("polls", "0011_basepoll_rankedchoiceballot_rankedchoiceoption_and_more"), 13 | ("wagtailcore", "0094_alter_page_locale"), 14 | ] 15 | 16 | operations = [ 17 | migrations.AlterModelOptions( 18 | name="majorityvotechoice", 19 | options={}, 20 | ), 21 | migrations.AlterModelOptions( 22 | name="rankedchoiceoption", 23 | options={}, 24 | ), 25 | migrations.AddField( 26 | model_name="majorityvotechoice", 27 | name="translation_key", 28 | field=models.UUIDField(null=True, editable=False), 29 | ), 30 | migrations.AddField( 31 | model_name="majorityvotechoice", 32 | name="locale", 33 | field=models.ForeignKey( 34 | null=True, 35 | editable=False, 36 | to="wagtailcore.locale", 37 | on_delete=models.CASCADE, 38 | ), 39 | ), 40 | migrations.AddField( 41 | model_name="rankedchoiceoption", 42 | name="translation_key", 43 | field=models.UUIDField(null=True, editable=False), 44 | ), 45 | migrations.AddField( 46 | model_name="rankedchoiceoption", 47 | name="locale", 48 | field=models.ForeignKey( 49 | null=True, 50 | editable=False, 51 | to="wagtailcore.locale", 52 | on_delete=models.CASCADE, 53 | ), 54 | ), 55 | # Remove unique_together for now, will add in a later migration 56 | ] 57 | -------------------------------------------------------------------------------- /myhpi/static/js/admin/text_array_widget.js: -------------------------------------------------------------------------------- 1 | const addTextArrayItem = function (id) { 2 | // create a new item 3 | const container = document.getElementById(`text-array-${id}`) 4 | const wrapper = document.createElement("div") 5 | const input = document.createElement("input") 6 | const remover = document.createElement("span") 7 | wrapper.append(input) 8 | wrapper.append(remover) 9 | container.append(wrapper) 10 | input.type = "text" 11 | // prevent submission on enter 12 | input.addEventListener("keydown", function (event) { 13 | onTextArrayItemInputKeyDown(id, event, this) 14 | }) 15 | // synchronize AFTER the character has been written 16 | input.addEventListener("keyup", function () { 17 | synchronizeTextArray(id) 18 | }) 19 | remover.innerText = "X" 20 | remover.addEventListener("click", function () { 21 | removeTextArrayItem(id, this) 22 | }) 23 | synchronizeTextArray(id) 24 | return input 25 | } 26 | 27 | const removeTextArrayItem = function (id, inputNode) { 28 | inputNode.parentNode.remove() 29 | synchronizeTextArray(id) 30 | } 31 | 32 | const onTextArrayItemInputKeyDown = function (id, event, inputNode) { 33 | if (event.key === "Enter") { 34 | // prevent form from being submitted 35 | event.preventDefault() 36 | // add new item 37 | addTextArrayItem(id).focus() 38 | } 39 | synchronizeTextArray(id) 40 | } 41 | 42 | const synchronizeTextArray = function (id) { 43 | const container = document.getElementById(`text-array-${id}`) 44 | const syncInput = document.getElementById(`id-${id}`) 45 | const inputs = container.querySelectorAll("input") 46 | // get all items, filter for empty inputs and write serialized list to dedicated input 47 | const items = Array.from(inputs) 48 | .map(function (input) { 49 | return input.value.trim() 50 | }) 51 | .filter(function (item) { 52 | return item.length !== 0 53 | }) 54 | syncInput.value = JSON.stringify(items) 55 | } 56 | -------------------------------------------------------------------------------- /myhpi/tests/core/utils.py: -------------------------------------------------------------------------------- 1 | from django.test import Client, TestCase 2 | 3 | from myhpi.tests.core.setup import setup_data 4 | 5 | 6 | class MyHPIPageTestCase(TestCase): 7 | def setUp(self): 8 | self.client = Client() 9 | self.test_data = setup_data() 10 | 11 | self.root_page = self.test_data["basic_pages"]["root_page"] 12 | self.information_menu = self.test_data["basic_pages"]["information_menu"] 13 | self.student_representation_menu = self.test_data["basic_pages"][ 14 | "student_representation_menu" 15 | ] 16 | self.fsr_menu = self.test_data["basic_pages"]["fsr_menu"] 17 | 18 | self.common_page = self.test_data["pages"][0] 19 | self.private_page = self.test_data["pages"][1] 20 | self.public_page = self.test_data["pages"][2] 21 | self.hidden_public_page = self.test_data["pages"][3] 22 | 23 | self.minutes = self.test_data["minutes"] 24 | self.student_representative_group_minutes = self.test_data["minutes_list"] 25 | 26 | self.super_user = self.test_data["users"][0] 27 | self.student = self.test_data["users"][1] 28 | self.student_representative = self.test_data["users"][2] 29 | 30 | self.first_document = self.test_data["documents"][0] 31 | self.second_document = self.test_data["documents"][1] 32 | 33 | def sign_in_as_student(self): 34 | self.client.force_login(self.student) 35 | 36 | def sign_in_as_student_representative(self): 37 | self.client.force_login(self.student_representative) 38 | 39 | def sign_in_as_super_user(self): 40 | self.client.force_login(self.super_user) 41 | 42 | 43 | def ensure_ancestors_translated(page, locale): 44 | parent = page.get_parent().specific 45 | if parent.depth > 1: 46 | ensure_ancestors_translated(parent, locale) 47 | if not parent.get_translations(inclusive=True).filter(locale=locale).exists(): 48 | parent.copy_for_translation(locale).save() 49 | -------------------------------------------------------------------------------- /myhpi/core/context.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db.models import Q 3 | from wagtail.models import Site 4 | 5 | from .models import BasePage, MinutesList 6 | from .utils import get_user_groups 7 | 8 | 9 | def base_context(request): 10 | # How wagtail page trees work: https://www.accordbox.com/blog/how-to-create-and-manage-menus-in-wagtail/ 11 | 12 | # Fetch all pages 13 | root_page = getattr(Site.find_for_request(request), "root_page", None) 14 | pages = BasePage.objects.live() 15 | 16 | if not root_page: 17 | return {"root_page": None, "pages_by_parent": None} 18 | 19 | # Determine the correct root for the active language 20 | root_page = root_page.localized 21 | 22 | # Determine all pages the user may view based on his groups 23 | user_groups = get_user_groups(request.user) 24 | 25 | pages_visible_for_user = pages.filter( 26 | (Q(visible_for__in=user_groups) | Q(is_public=True)) 27 | & (Q(show_in_menus=True) | Q(id=root_page.id)) 28 | ).distinct() 29 | 30 | page_lookup = {} 31 | 32 | for page in pages_visible_for_user: 33 | page_lookup[page.path] = pages_visible_for_user.child_of(page).order_by("path") 34 | 35 | minutes_creation_links = {} 36 | for group in request.user.groups.all(): 37 | minutes_creation_links[group.id] = create_minutes_for_group_link(request.user, group) 38 | 39 | return { 40 | "root_page": root_page, 41 | "pages_by_parent": page_lookup, 42 | "minutes_creation_links": minutes_creation_links, 43 | "template_cache_duration": 1 if settings.DEBUG else 500, 44 | } 45 | 46 | 47 | def create_minutes_for_group_link(user, group): 48 | minutes_list = MinutesList.objects.filter(group=group).first() 49 | if not minutes_list: 50 | return None 51 | if minutes_list.permissions_for_user(user).can_add_subpage(): 52 | from django.urls import reverse 53 | 54 | return reverse("wagtailadmin_pages:add_subpage", kwargs={"parent_page_id": minutes_list.id}) 55 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_auth.py: -------------------------------------------------------------------------------- 1 | from myhpi.core.auth import MyHPIOIDCAB 2 | from myhpi.tests.core.utils import MyHPIPageTestCase 3 | 4 | 5 | class AuthTests(MyHPIPageTestCase): 6 | def setUp(self): 7 | super().setUp() 8 | self.auth_backend = MyHPIOIDCAB() 9 | 10 | def test_create_user(self): 11 | claims = { 12 | "email": "ali.gator@example.org", 13 | "given_name": "Ali", 14 | "family_name": "Gator", 15 | "sub": "ali.gator", 16 | } 17 | user = self.auth_backend.create_user(claims) 18 | self.assertEqual(user.username, "ali.gator") 19 | self.assertFalse(user.groups.filter(name="Student").exists()) 20 | 21 | matching_users = self.auth_backend.filter_users_by_claims(claims) 22 | self.assertEqual(len(matching_users), 1) 23 | 24 | def test_create_student(self): 25 | claims = { 26 | "email": "grace.hopper@student.uni-potsdam.example.com", 27 | "given_name": "Grace", 28 | "family_name": "Hopper", 29 | "sub": "grace.hopper", 30 | } 31 | user = self.auth_backend.create_user(claims) 32 | self.assertEqual(user.username, "grace.hopper") 33 | self.assertEqual(user.email, "grace.hopper@student.uni-potsdam.example.com") 34 | 35 | def test_update_user(self): 36 | claims = { 37 | "email": "jw.goethe@weimar.de", 38 | "given_name": "Johann Wolfgang", 39 | "family_name": "Goethe", 40 | "sub": "jw.goethe", 41 | } 42 | user = self.auth_backend.create_user(claims) 43 | self.assertEqual(user.username, "jw.goethe") 44 | self.assertEqual(user.last_name, "Goethe") 45 | claims["family_name"] = "von Goethe" 46 | claims["email"] = "jw.goethe@weimar.eu" 47 | user = self.auth_backend.update_user(user, claims) 48 | self.assertEqual(user.first_name, "Johann Wolfgang") 49 | self.assertEqual(user.last_name, "von Goethe") 50 | self.assertEqual(user.email, "jw.goethe@weimar.eu") 51 | -------------------------------------------------------------------------------- /myhpi/tenca_django/templates/tenca_django/manage_subscription.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %} 5 | {% trans "Manage Mailing List Subscription" %} 6 | {% endblock %} 7 | 8 | {% block breadcrumbs %} 9 | {% if request.user.is_authenticated %} 10 | 13 | 14 | {% endif %} 15 | {% endblock %} 16 | 17 | {% block content %} 18 | 19 |

    20 | {% trans "Manage Mailing List Subscription" %} 21 |

    22 | 23 |
    24 |
    25 | {% trans "List" %}: {{ list_name }} 26 |
    27 |
    28 | 29 |
    30 |
    31 |

    32 | {% blocktrans trimmed %} 33 | Enter the e-mail address you want to un-/subscribe from/to {{ list_name }}: 34 | {% endblocktrans %} 35 |

    36 | 37 |
    38 | {% csrf_token %} 39 |
    40 | 41 | 48 |
    49 |
    50 | 53 |
    54 |
    55 |
    56 |
    57 | {% endblock %} 58 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_page_creation.py: -------------------------------------------------------------------------------- 1 | from wagtail.test.utils import WagtailPageTests 2 | 3 | from myhpi.core.models import ( 4 | FirstLevelMenuItem, 5 | InformationPage, 6 | Minutes, 7 | MinutesList, 8 | RootPage, 9 | SecondLevelMenuItem, 10 | ) 11 | 12 | 13 | class PageCreationTests(WagtailPageTests): 14 | def test_can_create_a_first_level_menu_under_root_page(self): 15 | self.assertCanCreateAt(RootPage, FirstLevelMenuItem) 16 | 17 | def test_cant_create_a_first_level_menu_under_information_page(self): 18 | self.assertCanNotCreateAt(InformationPage, FirstLevelMenuItem) 19 | 20 | def test_can_create_a_second_level_menu_under_first_level_menu(self): 21 | self.assertCanCreateAt(FirstLevelMenuItem, SecondLevelMenuItem) 22 | 23 | def test_cant_create_a_second_level_menu_under_root_page(self): 24 | self.assertCanNotCreateAt(RootPage, SecondLevelMenuItem) 25 | 26 | def test_cant_create_a_second_level_menu_under_information_page(self): 27 | self.assertCanNotCreateAt(InformationPage, SecondLevelMenuItem) 28 | 29 | def test_can_create_a_information_page_under_first_level_menu(self): 30 | self.assertCanCreateAt(FirstLevelMenuItem, InformationPage) 31 | 32 | def test_can_create_a_information_page_under_second_level_menu(self): 33 | self.assertCanCreateAt(SecondLevelMenuItem, InformationPage) 34 | 35 | def test_cant_create_a_information_page_under_information_page(self): 36 | self.assertCanCreateAt(InformationPage, InformationPage) 37 | 38 | def test_can_create_a_minutes_list_under_root_page(self): 39 | self.assertCanCreateAt(RootPage, MinutesList) 40 | 41 | def test_can_create_a_minutes_list_under_first_level_menu(self): 42 | self.assertCanCreateAt(FirstLevelMenuItem, MinutesList) 43 | 44 | def test_can_create_a_minutes_list_under_second_level_menu(self): 45 | self.assertCanCreateAt(SecondLevelMenuItem, MinutesList) 46 | 47 | def test_can_only_create_minutes_under_minutes_pages(self): 48 | self.assertAllowedParentPageTypes(Minutes, {MinutesList}) 49 | -------------------------------------------------------------------------------- /myhpi/core/auth.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.models import Group 2 | from mozilla_django_oidc.auth import OIDCAuthenticationBackend 3 | 4 | 5 | def mail_replacement(email): 6 | return f"{email.split('@')[0]}@{email.split('@')[1].replace('uni-potsdam.', '')}" 7 | 8 | 9 | class MyHPIOIDCAB(OIDCAuthenticationBackend): 10 | def _update_groups(self, user, claims): 11 | group_names = claims.get("roles", []) 12 | groups = set() 13 | for group in group_names: 14 | try: 15 | # djangos get_or_create does not work with __iexact, therefore using try/catch 16 | groups.add(Group.objects.get(name__iexact=group)) 17 | except Group.DoesNotExist: 18 | groups.add(Group.objects.create(name=group)) 19 | user.groups.set(groups) 20 | 21 | def create_user(self, claims): 22 | email = claims.get("email") 23 | first_name = claims.get("given_name", "") 24 | last_name = claims.get("family_name", "") 25 | username = claims.get("sub") 26 | 27 | user = self.UserModel.objects.create_user( 28 | username, email=email, first_name=first_name, last_name=last_name 29 | ) 30 | self._update_groups(user, claims) 31 | return user 32 | 33 | def update_user(self, user, claims): 34 | user.username = claims.get("sub") 35 | user.email = claims.get("email") 36 | user.first_name = claims.get("given_name", "") 37 | user.last_name = claims.get("family_name", "") 38 | self._update_groups(user, claims) 39 | user.save() 40 | 41 | return user 42 | 43 | def filter_users_by_claims(self, claims): 44 | """Return all users matching the specified username.""" 45 | username = claims.get("sub") 46 | if not username: 47 | return self.UserModel.objects.none() 48 | users = self.UserModel.objects.filter(username__iexact=username) 49 | if not users.exists(): 50 | users = self.UserModel.objects.filter( 51 | email__iexact=mail_replacement(claims.get("email")) 52 | ) 53 | return users 54 | -------------------------------------------------------------------------------- /myhpi/core/templates/core/information_page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load core_extras %} 3 | {% load i18n %} 4 | {% load tz %} 5 | 6 | {% block content %} 7 | {% with page.body|markdown as parsed_md %} 8 | {% include "core/toc_button.html" with toc=parsed_md.1 attachments=page.attachments.exists %} 9 |
    10 |
    11 |

    12 | {{ page.title }} 13 |

    14 | {{ parsed_md.0|touchify_abbreviations|tag_external_links }} 15 |
    16 |
    17 |
    18 | 37 | {% include "core/sidebar.html" %} 38 |
    39 |
    40 |
    41 | {% endwith %} 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /myhpi/search/templatetags/search.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django import template 4 | from django.utils.html import escape 5 | from django.utils.safestring import mark_safe 6 | 7 | from myhpi.core.markdown.utils import render_markdown 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.filter(name="highlight_query_markdown") 13 | # select 3 lines around first match and highlight query match 14 | def highlight_query(content, search_query, surrounding_lines=1): 15 | lines = content.split("\n") 16 | for i, line in enumerate(lines): 17 | if search_query.lower() in line.lower(): 18 | lines[i] = line 19 | # try finding the last leading heading to include it in the snippet 20 | trailing_heading = None 21 | # skip if the current line already is a heading 22 | if not line.startswith("#"): 23 | for j in range(i - 1, -1, -1): 24 | if lines[j].startswith("#"): 25 | trailing_heading = lines[j] 26 | break 27 | # take 3 lines around match 28 | start = max(0, i - 1) 29 | end = min(len(lines), i + 2) 30 | lines = lines[start:end] 31 | if trailing_heading: 32 | lines.insert(0, trailing_heading) 33 | break 34 | excerpt_max_length = surrounding_lines * 2 + 1 35 | if len(lines) > excerpt_max_length: 36 | lines = lines[:excerpt_max_length] 37 | markdown = "\n".join(lines) 38 | # Replace search query with bold version but preserve case from markdown 39 | markdown = re.sub( 40 | re.compile(f"({search_query})", re.IGNORECASE), 41 | r"**\1**", 42 | markdown, 43 | ) 44 | rendered_markdown = render_markdown(markdown, None, False)[0] 45 | return rendered_markdown 46 | 47 | 48 | @register.filter(name="highlight_title") 49 | def highlight_title(title, search_query): 50 | title = escape(title) 51 | return mark_safe( 52 | re.sub( 53 | re.compile(f"({search_query})", re.IGNORECASE), 54 | r"\1", 55 | title, 56 | ) 57 | ) 58 | -------------------------------------------------------------------------------- /myhpi/polls/templates/polls/majority_vote_poll.html: -------------------------------------------------------------------------------- 1 | {% extends "polls/base_poll.html" %} 2 | {% load polls %} 3 | {% load core_extras %} 4 | {% load i18n %} 5 | 6 | {% block ballot %} 7 | {% with canonical_poll=page.get_canonical_poll %} 8 |
    9 | {% csrf_token %} 10 | {% for choice in canonical_poll.choices.all %} 11 | {% with choice|get_localized_choice:request as localized_choice %} 12 | 16 | 17 | {% endwith %} 18 |
    19 | {% endfor %} 20 | 21 |
    22 | {% endwith %} 23 | {% endblock %} 24 | 25 | {% block results %} 26 | {% with canonical_poll=page.get_canonical_poll %} 27 | 28 | 29 | 30 | {% if has_choice_descriptions %} 31 | 32 | {% endif %} 33 | 34 | 35 | 36 | {% for choice in canonical_poll.choices.all %} 37 | {% with choice|get_localized_choice:request as localized_choice %} 38 | 39 | 40 | {% if has_choice_descriptions %} 41 | 42 | {% endif %} 43 | 44 | 45 | 46 | {% endwith %} 47 | {% endfor %} 48 |
    {% trans "Choice" %}{% trans "Description" %}{% trans "Votes" %}{% trans "Percentage" %}
    {{ localized_choice.text }}{{ localized_choice.description|default_if_none:"" }}{{ choice.votes }}{{ choice.percentage }}%
    49 | {% endwith %} 50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.8 on 2021-06-12 09:51 2 | 3 | import django.db.models.deletion 4 | import modelcluster.fields 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | initial = True 10 | 11 | dependencies = [ 12 | ("wagtailcore", "0060_fix_workflow_unique_constraint"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Poll", 18 | fields=[ 19 | ( 20 | "basepage_ptr", 21 | models.OneToOneField( 22 | auto_created=True, 23 | on_delete=django.db.models.deletion.CASCADE, 24 | parent_link=True, 25 | primary_key=True, 26 | serialize=False, 27 | to="core.basepage", 28 | ), 29 | ), 30 | ], 31 | options={ 32 | "abstract": False, 33 | }, 34 | bases=("core.basepage",), 35 | ), 36 | migrations.CreateModel( 37 | name="PollChoice", 38 | fields=[ 39 | ( 40 | "id", 41 | models.AutoField( 42 | auto_created=True, primary_key=True, serialize=False, verbose_name="ID" 43 | ), 44 | ), 45 | ("sort_order", models.IntegerField(blank=True, editable=False, null=True)), 46 | ("text", models.CharField(max_length=200)), 47 | ("description", models.TextField(blank=True, default="")), 48 | ("votes", models.IntegerField(default=0)), 49 | ( 50 | "page", 51 | modelcluster.fields.ParentalKey( 52 | on_delete=django.db.models.deletion.CASCADE, 53 | related_name="choices", 54 | to="polls.poll", 55 | ), 56 | ), 57 | ], 58 | options={ 59 | "ordering": ["sort_order"], 60 | "abstract": False, 61 | }, 62 | ), 63 | ] 64 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_minutes.py: -------------------------------------------------------------------------------- 1 | from myhpi.tests.core.utils import MyHPIPageTestCase 2 | 3 | 4 | class MinutesTests(MyHPIPageTestCase): 5 | def test_minutes_links_to_neighboring_minutes(self): 6 | self.sign_in_as_student() 7 | minutes = self.client.get( 8 | "/en/student-representation/fsr/minutes/second-minutes", follow=True 9 | ) 10 | self.assertIn("Previous minutes", minutes.rendered_content) 11 | self.assertIn("Next minutes", minutes.rendered_content) 12 | 13 | self.assertIn("first-minutes", minutes.rendered_content) 14 | self.assertNotIn("private-minutes", minutes.rendered_content) 15 | self.assertNotIn("unpublished-minutes", minutes.rendered_content) 16 | self.assertIn("recent-minutes", minutes.rendered_content) 17 | 18 | def test_minutes_links_to_neighboring_minutes_for_student_representatives(self): 19 | self.sign_in_as_student_representative() 20 | minutes = self.client.get( 21 | "/en/student-representation/fsr/minutes/second-minutes", follow=True 22 | ) 23 | self.assertIn("Previous minutes", minutes.rendered_content) 24 | self.assertIn("Next minutes", minutes.rendered_content) 25 | 26 | self.assertIn("first-minutes", minutes.rendered_content) 27 | self.assertIn("private-minutes", minutes.rendered_content) 28 | self.assertNotIn("unpublished-minutes", minutes.rendered_content) 29 | self.assertNotIn("recent-minutes", minutes.rendered_content) 30 | 31 | def test_most_recent_minutes_does_not_have_link_to_next_minutes(self): 32 | self.sign_in_as_student() 33 | minutes = self.client.get( 34 | "/en/student-representation/fsr/minutes/recent-minutes", follow=True 35 | ) 36 | self.assertNotIn("Next minutes", minutes.rendered_content) 37 | self.assertIn("Previous minutes", minutes.rendered_content) 38 | 39 | def test_oldest_minutes_does_not_have_link_to_previous_minutes(self): 40 | self.sign_in_as_student() 41 | minutes = self.client.get( 42 | "/en/student-representation/fsr/minutes/first-minutes", follow=True 43 | ) 44 | self.assertNotIn("Previous minutes", minutes.rendered_content) 45 | self.assertIn("Next minutes", minutes.rendered_content) 46 | -------------------------------------------------------------------------------- /myhpi/polls/templates/polls/base_poll.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load polls %} 3 | {% load core_extras %} 4 | {% load i18n %} 5 | 6 | {% block content %} 7 | {% with page.description|markdown as parsed_md %} 8 |
    9 |
    10 |

    11 | {{ page.title }} 12 |

    13 | {{ parsed_md.0|touchify_abbreviations|tag_external_links }} 14 |

    {{ page.question }}

    15 | {% if page|can_vote:request %} 16 | {% block ballot %}{% endblock %} 17 | {% elif page.results_visible %} 18 | {% block results %}{% endblock %} 19 | {% elif not page.in_voting_period %} 20 |

    21 | {% translate "You've accessed this page outside of the voting period." %} 22 |

    23 | {% else %} 24 |

    25 | {% translate "You are not allowed to cast (another) vote and the results are not visible yet." %} 26 |

    27 | {% endif %} 28 |
    29 |
    30 |
    31 | 44 |
    45 |
    46 |
    47 | {% endwith %} 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /myhpi/polls/templates/polls/ranked_choice_poll.html: -------------------------------------------------------------------------------- 1 | {% extends "polls/base_poll.html" %} 2 | {% load polls %} 3 | {% load core_extras %} 4 | {% load i18n %} 5 | 6 | {% block ballot %} 7 | {% with canonical_poll=page.get_canonical_poll %} 8 |
    9 | {% csrf_token %} 10 |
    11 | {% for field in canonical_poll|get_localized_form:request %} 12 |
    13 |
    16 |
    17 |
    {{ field.label }}
    18 |
    {{ field }}
    19 |
    20 |
    21 |
    24 |
    25 | {% with field.help_text|markdown as parsed_md %} 26 |

    {{ parsed_md.0 }}

    27 | {% endwith %} 28 |
    29 |
    30 |
    31 | {% endfor %} 32 |
    33 | 34 |
    35 | {% endwith %} 36 | {% endblock %} 37 | 38 | {% block results %} 39 | {% with canonical_poll=page.get_canonical_poll %} 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | {% for choice in canonical_poll|get_localized_results:request %} 50 | 51 | 52 | 53 | 54 | 55 | {% endfor %} 56 | 57 |
    {% translate "Rank" %}{% translate "Choice" %}{% translate "Final Votes" %}
    {{ choice.0 }}{{ choice.1 }}{{ choice.2 }}
    58 | {% endwith %} 59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "myHPI" 3 | version = "0.0.0" # automatically set by poetry-dynamic-versioning 4 | description = "" 5 | authors = ["FSR DE "] 6 | license = "MIT" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.11" 10 | wagtail = "^6.4" 11 | django-environ = "^0.11.2" 12 | wagtail-localize = "^1.9" 13 | mozilla-django-oidc = "^4.0.0" 14 | django-bootstrap-icons = "^0.9.0" 15 | django-select2 = "^8.4.1" 16 | django-static-precompiler = {extras = ["libsass"], version = "^2.4"} 17 | django-debug-toolbar = "^4.4.6" 18 | django-permissionedforms = "^0.1" 19 | tenca = "^0.0.3" 20 | html2text = "^2020.1.16" 21 | wagtail-markdown = "^0.12.1" 22 | autoflake = "^2.2.1" 23 | setuptools-scm = "^8.0.4" 24 | django-prometheus = "^2.3.1" 25 | djlint = "^1.35.2" 26 | 27 | [tool.poetry.group.dev.dependencies] 28 | pylint = "^3.3.1" 29 | black = "^25.1" 30 | isort = "^5.12.0" 31 | autoflake = "^2.2.1" 32 | pre-commit = "^4.2.0" 33 | pytest = "^8.3.3" 34 | coverage = "^7.6.0" 35 | 36 | [tool.poetry.extras] 37 | pgsql = ["psycopg2"] 38 | mysql = ["mysqlclient"] 39 | 40 | [build-system] 41 | requires = ["poetry-core>=1.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"] 42 | build-backend = "poetry_dynamic_versioning.backend" 43 | 44 | [tool.black] 45 | line-length = 100 46 | # https://github.com/psf/black/blob/master/docs/compatible_configs.md 47 | 48 | [tool.isort] 49 | multi_line_output = 3 50 | include_trailing_comma = true 51 | force_grid_wrap = 0 52 | use_parentheses = true 53 | ensure_newline_before_comments = true 54 | line_length = 100 55 | skip_gitignore = true 56 | 57 | [tool.pylint.messages_control] 58 | # C0330 and C0301 are disabled for use of black 59 | disable = """C0330, C0301, 60 | duplicate-code, attribute-defined-outside-init, missing-module-docstring, missing-class-docstring, 61 | no-member, invalid-name, import-outside-toplevel, unused-argument, too-many-ancestors, missing-function-docstring, 62 | too-few-public-methods, no-self-use, too-many-arguments, cyclic-import, inconsistent-return-statements, 63 | useless-object-inheritance, logging-fstring-interpolation 64 | """ 65 | 66 | [tool.pylint.format] 67 | max-line-length = "100" 68 | 69 | [tool.poetry-dynamic-versioning] 70 | enable = true 71 | 72 | [tool.djlint] 73 | ignore = "T002,T003,H006,H023,H031,H037" 74 | preserve_blank_lines = true 75 | custom_blocks = "blocktrans,cache" 76 | max_line_length=80 77 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [main] 9 | schedule: 10 | - cron: "44 16 * * 0" 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: ["javascript", "python"] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 26 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v3 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v2 35 | with: 36 | languages: ${{ matrix.language }} 37 | # If you wish to specify custom queries, you can do so here or in a config file. 38 | # By default, queries listed here will override any specified in a config file. 39 | # Prefix the list here with "+" to use these queries and those in the config file. 40 | 41 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 42 | # queries: security-extended,security-and-quality 43 | 44 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 45 | # If this step fails, then you should remove it and run the build manually (see below) 46 | - name: Autobuild 47 | uses: github/codeql-action/autobuild@v2 48 | 49 | # ℹ️ Command-line programs to run using the OS shell. 50 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 51 | 52 | # If the Autobuild fails above, remove it and uncomment the following three lines. 53 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 54 | 55 | # - run: | 56 | # echo "Run, Build Application using script" 57 | # ./location_of_script_within_repo/buildscript.sh 58 | 59 | - name: Perform CodeQL Analysis 60 | uses: github/codeql-action/analyze@v2 61 | -------------------------------------------------------------------------------- /myhpi/static/js/print_processor.js: -------------------------------------------------------------------------------- 1 | function processPageForPrinting() { 2 | moveLinksToFooter() 3 | expandAbbreviations() 4 | } 5 | 6 | function moveLinksToFooter() { 7 | let content = document.getElementsByClassName("minutes-text")[0] 8 | if (!content) return 9 | 10 | let footer = document.getElementById("minutes-footer") 11 | let linkList = document.createElement("ol") 12 | 13 | let articleLinks = content.getElementsByTagName("a") 14 | for (let i = 0; i < articleLinks.length; i++) { 15 | const articleLink = articleLinks[i] 16 | articleLink.innerHTML += 17 | "[" + (i + 1) + "]" 18 | let footnote = document.createElement("li") 19 | footnote.innerText = articleLink.href 20 | linkList.appendChild(footnote) 21 | } 22 | footer.appendChild(linkList) 23 | } 24 | 25 | function expandAbbreviations() { 26 | let content = document.getElementsByClassName("minutes-text")[0] 27 | if (!content) return 28 | let abbreviations = content.getElementsByTagName("abbr") 29 | for (let i = 0; i < abbreviations.length; i++) { 30 | let short = abbreviations[i].innerText 31 | let long = abbreviations[i].getAttribute("title") 32 | 33 | let replacement = document.createElement("span") 34 | replacement.classList.add("print-expanded-abbr") 35 | replacement.setAttribute("short", short) 36 | replacement.setAttribute("long", long) 37 | replacement.innerText = long 38 | abbreviations[i].parentNode.replaceChild(replacement, abbreviations[i]) 39 | } 40 | } 41 | 42 | function removePrintingProcessing() { 43 | let content = document.getElementsByClassName("minutes-text")[0] 44 | if (!content) return 45 | let footer = document.getElementById("minutes-footer") 46 | footer.innerText = "" 47 | 48 | let generated = document.getElementsByClassName("print-generated-tag") 49 | while (generated.length > 0) { 50 | generated[0].remove() 51 | } 52 | 53 | let abbreviationReplacements = content.getElementsByClassName( 54 | "print-expanded-abbr", 55 | ) 56 | for (let i = 0; i < abbreviationReplacements.length; i++) { 57 | let replacement = abbreviationReplacements[i] 58 | let short = replacement.getAttribute("short") 59 | let long = replacement.getAttribute("long") 60 | 61 | let abbr = document.createElement("abbr") 62 | abbr.setAttribute("title", long) 63 | abbr.innerText = short 64 | replacement.parentNode.replaceChild(abbr, replacement) 65 | } 66 | } 67 | 68 | addEventListener("beforeprint", processPageForPrinting) 69 | addEventListener("afterprint", removePrintingProcessing) 70 | -------------------------------------------------------------------------------- /myhpi/tenca_django/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.urls import reverse 3 | from django.utils.html import format_html 4 | from django.utils.translation import gettext_lazy as _ 5 | from tenca.hash_storage import ( 6 | HashStorage, 7 | MailmanDescriptionHashStorage, 8 | NotInStorageError, 9 | TwoLevelHashStorage, 10 | ) 11 | 12 | 13 | class TencaModel(models.Model): 14 | class Meta: 15 | abstract = True 16 | app_label = "tenca_django" 17 | 18 | 19 | class HashEntry(TencaModel): 20 | hash_id = models.CharField(max_length=64, unique=True, blank=False, null=False) 21 | list_id = models.CharField(max_length=128, blank=False, null=False) 22 | 23 | def __str__(self): 24 | return "HashEntry for {}".format(self.list_id) 25 | 26 | @property 27 | def manage_page(self): 28 | return format_html( 29 | '{text}'.format( 30 | text=_("Manage List"), 31 | url=reverse("tenca_django:tenca_manage_list", args=(self.list_id,)), 32 | ) 33 | ) 34 | 35 | class Meta(TencaModel.Meta): 36 | verbose_name = "Mailing List Hash Entry" 37 | verbose_name_plural = "Mailing List Hash Entries" 38 | 39 | 40 | class DjangoModelHashStorage(HashStorage): 41 | def __contains__(self, hash_id): 42 | try: 43 | HashEntry.objects.get(hash_id=hash_id) 44 | return True 45 | except HashEntry.DoesNotExist: 46 | return False 47 | 48 | def get_list_id(self, hash_id): 49 | try: 50 | entry = HashEntry.objects.get(hash_id=hash_id) 51 | except HashEntry.DoesNotExist: 52 | raise NotInStorageError() 53 | else: 54 | return entry.list_id 55 | 56 | def store_list_id(self, hash_id, list_id): 57 | entry = HashEntry(hash_id=hash_id, list_id=list_id) 58 | entry.save() 59 | 60 | def get_hash_id(self, list_id): 61 | try: 62 | entry = HashEntry.objects.get(list_id=list_id) 63 | except HashEntry.DoesNotExist: 64 | raise NotInStorageError() 65 | else: 66 | return entry.hash_id 67 | 68 | def delete_hash_id(self, hash_id): 69 | try: 70 | entry = HashEntry.objects.get(hash_id=hash_id) 71 | except HashEntry.DoesNotExist: 72 | pass 73 | else: 74 | entry.delete() 75 | 76 | def hashes(self): 77 | return (e.hash_id for e in HashEntry.objects.all()) 78 | 79 | 80 | class DjangoModelCachedDescriptionHashStorage(TwoLevelHashStorage): 81 | l1_class = DjangoModelHashStorage 82 | l2_class = MailmanDescriptionHashStorage 83 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_menu.py: -------------------------------------------------------------------------------- 1 | from myhpi.tests.core.utils import MyHPIPageTestCase 2 | 3 | 4 | def get_as_page_path_lookup(page_lookup): 5 | page_path_lookup = {} 6 | for page_path in page_lookup: 7 | page_path_lookup[page_path] = [child.path for child in page_lookup[page_path]] 8 | return page_path_lookup 9 | 10 | 11 | class MenuTests(MyHPIPageTestCase): 12 | def test_root_page_in_context(self): 13 | start_page = self.client.get("/en", follow=True) 14 | self.assertEqual(self.root_page.path, start_page.context["root_page"].path) 15 | 16 | def test_page_lookup_is_correct_for_guests(self): 17 | start_page = self.client.get("/en", follow=True) 18 | lookup = get_as_page_path_lookup(start_page.context["pages_by_parent"]) 19 | self.assertDictEqual( 20 | { 21 | self.root_page.path: [self.information_menu.path], 22 | self.information_menu.path: [self.public_page.path], 23 | self.public_page.path: [], 24 | }, 25 | lookup, 26 | ) 27 | 28 | def test_page_lookup_is_correct_for_students(self): 29 | self.sign_in_as_student() 30 | start_page = self.client.get("/en", follow=True) 31 | lookup = get_as_page_path_lookup(start_page.context["pages_by_parent"]) 32 | self.maxDiff = None 33 | self.assertDictEqual( 34 | { 35 | self.root_page.path: [self.information_menu.path], 36 | self.information_menu.path: [self.common_page.path, self.public_page.path], 37 | self.public_page.path: [], 38 | self.common_page.path: [], 39 | self.student_representative_group_minutes.path: [], 40 | }, 41 | lookup, 42 | ) 43 | 44 | def test_page_lookup_is_correct_for_student_representatives(self): 45 | self.sign_in_as_student_representative() 46 | start_page = self.client.get("/en", follow=True) 47 | lookup = get_as_page_path_lookup(start_page.context["pages_by_parent"]) 48 | self.maxDiff = None 49 | self.assertDictEqual( 50 | { 51 | self.root_page.path: [self.information_menu.path], 52 | self.information_menu.path: [ 53 | self.common_page.path, 54 | self.private_page.path, 55 | self.public_page.path, 56 | ], 57 | self.public_page.path: [], 58 | self.common_page.path: [], 59 | self.private_page.path: [], 60 | self.student_representative_group_minutes.path: [], 61 | }, 62 | lookup, 63 | ) 64 | -------------------------------------------------------------------------------- /myhpi/core/widgets.py: -------------------------------------------------------------------------------- 1 | from json import loads as parse_json 2 | 3 | from django import forms 4 | from wagtail.documents.models import Document 5 | 6 | 7 | class AttachmentSelectWidget(forms.SelectMultiple): 8 | allow_multiple_selected = True 9 | current_user = None 10 | 11 | def __init__(self, attrs=None, choices=(), user=None): 12 | if user: 13 | self.current_user = user 14 | super().__init__(attrs, choices) 15 | 16 | def optgroups(self, name, value, attrs=None): 17 | """Return a list of optgroups for this widget.""" 18 | groups = [] 19 | has_selected = False 20 | 21 | attachments = [] 22 | for option_value, option_label in self.choices: 23 | if not self.current_user: 24 | attachments.append((option_value, option_label)) 25 | else: 26 | document = Document.objects.get(id=option_value.value) 27 | if document.is_editable_by_user(user=self.current_user): 28 | attachments.append((option_value, option_label)) 29 | 30 | for index, (option_value, option_label) in enumerate(attachments): 31 | if option_value is None: 32 | option_value = "" 33 | 34 | subgroup = [] 35 | if isinstance(option_label, (list, tuple)): 36 | group_name = option_value 37 | subindex = 0 38 | choices = option_label 39 | else: 40 | group_name = None 41 | subindex = None 42 | choices = [(option_value, option_label)] 43 | groups.append((group_name, subgroup, index)) 44 | 45 | for subvalue, sublabel in choices: 46 | selected = (not has_selected or self.allow_multiple_selected) and str( 47 | subvalue 48 | ) in value 49 | has_selected |= selected 50 | subgroup.append( 51 | self.create_option( 52 | name, 53 | subvalue, 54 | sublabel, 55 | selected, 56 | index, 57 | subindex=subindex, 58 | attrs=attrs, 59 | ) 60 | ) 61 | if subindex is not None: 62 | subindex += 1 63 | return groups 64 | 65 | 66 | class TextArrayWidget(forms.Widget): 67 | template_name = "core/text_array_widget.html" 68 | 69 | class Media: 70 | css = {"all": ["css/text_array_widget.css"]} 71 | js = ["js/admin/text_array_widget.js"] 72 | 73 | def get_context(self, name, value, attrs): 74 | context = super().get_context(name, value, attrs) 75 | context["strings"] = parse_json(context["widget"]["value"]) 76 | return context 77 | -------------------------------------------------------------------------------- /myhpi/tenca_django/templates/tenca_django/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load tenca_extras %} 4 | 5 | {% block title %} 6 | {% trans "Mailing lists" %} 7 | {% endblock %} 8 | 9 | {% block breadcrumbs %} 10 | 11 | {% endblock %} 12 | 13 | {% block content %} 14 |

    15 | {% trans "Mailing Lists" %} 16 |

    17 |
    18 |
    19 |

    {% trans "Create New Mailing List" %}

    20 |
    21 | {% csrf_token %} 22 |
    23 | 24 |
    25 | 31 |
    32 | @lists.myhpi.de 33 |
    34 |
    35 |
    36 |
    37 | 40 |
    41 |
    42 |
    43 |
    44 | 45 | {% if memberships %} 46 |
    47 |
    48 |

    {% trans "Your Subscriptions" %}

    49 |
    50 |
    51 | {% for list_id, hash_id, is_owner in memberships %} 52 |
    53 | {{ list_id|fqdn_ize }} 54 | {% if is_owner %} 55 | 61 | {% endif %} 62 |
    63 | {% endfor %} 64 |
    65 |
    66 | {% endif %} 67 | {% endblock %} 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | data 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | .pybuilder/ 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | # For a library or package, you might want to ignore these files since the code is 82 | # intended to run in multiple environments; otherwise, check them in: 83 | # .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 93 | __pypackages__/ 94 | 95 | # Celery stuff 96 | celerybeat-schedule 97 | celerybeat.pid 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv* 105 | env/ 106 | venv*/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | /site 120 | 121 | # mypy 122 | .mypy_cache/ 123 | .dmypy.json 124 | dmypy.json 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | 129 | # pytype static type analyzer 130 | .pytype/ 131 | 132 | # Cython debug symbols 133 | cython_debug/ 134 | 135 | # IDEA files 136 | .idea/ 137 | .fleet/ 138 | 139 | # Visual Studio Code files 140 | .vscode/ 141 | 142 | # Database files 143 | *.sqlite3 144 | 145 | bootstrap/ 146 | bootstrap.bundle.min.js* 147 | tmp/ 148 | logs/ 149 | -------------------------------------------------------------------------------- /myhpi/urls.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | from django.conf.urls.i18n import i18n_patterns 5 | from django.contrib import admin 6 | from django.contrib.auth import views as auth_views 7 | from django.urls import include, path, reverse_lazy 8 | from django.views.generic import RedirectView 9 | from wagtail import urls as wagtail_urls 10 | from wagtail.admin import urls as wagtailadmin_urls 11 | from wagtail.documents import urls as wagtaildocs_urls 12 | 13 | from myhpi.search import views as search_views 14 | 15 | urlpatterns = [ 16 | path("django-admin/", admin.site.urls), 17 | path("admin/", include(wagtailadmin_urls)), 18 | path("documents/", include(wagtaildocs_urls)), 19 | path("oidc/", include("mozilla_django_oidc.urls")), 20 | path( 21 | "login/", 22 | auth_views.LoginView.as_view( 23 | template_name="login.html", 24 | redirect_authenticated_user=True, 25 | ), 26 | name="login", 27 | ), 28 | path("select2/", include("django_select2.urls")), 29 | path("__debug__/", include("debug_toolbar.urls")), 30 | path( 31 | ".well-known/security.txt", 32 | RedirectView.as_view(url=os.path.join(settings.STATIC_URL, "security.txt")), 33 | ), 34 | path("i18n/", include("django.conf.urls.i18n")), 35 | path("", include("myhpi.core.urls")), 36 | ] 37 | 38 | 39 | if settings.DEBUG: 40 | from django.conf.urls.static import static 41 | from django.contrib.staticfiles.urls import staticfiles_urlpatterns 42 | 43 | # Serve static and media files from development server 44 | urlpatterns += staticfiles_urlpatterns() 45 | urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 46 | 47 | if settings.ENABLE_MAILING_LISTS: 48 | urlpatterns += i18n_patterns( 49 | path("lists/", include("myhpi.tenca_django.urls")), 50 | # This is done manually, but shouldn't. It's all due to the ominous "problems with trailing slashes" 51 | path( 52 | "lists", 53 | RedirectView.as_view(url=reverse_lazy("tenca_django:tenca_dashboard")), 54 | name="tenca_index", 55 | ), 56 | ) 57 | 58 | urlpatterns += i18n_patterns( 59 | path("search/", search_views.search, name="search"), 60 | path("", include(wagtail_urls)), 61 | ) 62 | 63 | if settings.ENABLE_MAILING_LISTS: 64 | urlpatterns += i18n_patterns( 65 | path("lists/", include("myhpi.tenca_django.urls")), 66 | # This is done manually, but shouldn't. It's all due to the ominous "problems with trailing slashes" 67 | path( 68 | "lists", 69 | RedirectView.as_view(url=reverse_lazy("tenca_django:tenca_dashboard")), 70 | name="tenca_index", 71 | ), 72 | ) 73 | 74 | urlpatterns += i18n_patterns( 75 | path("search/", search_views.search, name="search"), 76 | path("", include(wagtail_urls)), 77 | ) 78 | -------------------------------------------------------------------------------- /myhpi/polls/migrations/0014_enforce_translation_key_locale_unique.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | 7 | def fix_null_locales(apps, schema_editor): 8 | Locale = apps.get_model("wagtailcore", "Locale") 9 | default_locale = Locale.objects.first() 10 | MajorityVoteChoice = apps.get_model("polls", "MajorityVoteChoice") 11 | RankedChoiceOption = apps.get_model("polls", "RankedChoiceOption") 12 | MajorityVoteChoice.objects.filter(locale__isnull=True).update(locale=default_locale) 13 | RankedChoiceOption.objects.filter(locale__isnull=True).update(locale=default_locale) 14 | 15 | 16 | class Migration(migrations.Migration): 17 | dependencies = [ 18 | ("polls", "0013_assign_translation_keys"), 19 | ] 20 | operations = [ 21 | migrations.AlterField( 22 | model_name="majorityvotechoice", 23 | name="translation_key", 24 | field=models.UUIDField(null=False, default=uuid.uuid4, editable=False), 25 | ), 26 | migrations.AlterField( 27 | model_name="majorityvotechoice", 28 | name="locale", 29 | field=models.ForeignKey( 30 | editable=False, 31 | on_delete=django.db.models.deletion.PROTECT, 32 | related_name="+", 33 | to="wagtailcore.locale", 34 | verbose_name="locale", 35 | ), 36 | ), 37 | migrations.AlterField( 38 | model_name="rankedchoiceoption", 39 | name="translation_key", 40 | field=models.UUIDField(null=False, default=uuid.uuid4, editable=False), 41 | ), 42 | migrations.AlterField( 43 | model_name="rankedchoiceoption", 44 | name="locale", 45 | field=models.ForeignKey( 46 | editable=False, 47 | on_delete=django.db.models.deletion.PROTECT, 48 | related_name="+", 49 | to="wagtailcore.locale", 50 | verbose_name="locale", 51 | ), 52 | ), 53 | migrations.AlterUniqueTogether( 54 | name="majorityvotechoice", 55 | unique_together={("translation_key", "locale")}, 56 | ), 57 | migrations.AlterUniqueTogether( 58 | name="rankedchoiceoption", 59 | unique_together={("translation_key", "locale")}, 60 | ), 61 | migrations.AlterModelOptions( 62 | name="majorityvotechoice", 63 | options={ 64 | "ordering": ["sort_order"], 65 | "abstract": False, 66 | }, 67 | ), 68 | migrations.AlterModelOptions( 69 | name="rankedchoiceoption", 70 | options={ 71 | "ordering": ["sort_order"], 72 | "abstract": False, 73 | }, 74 | ), 75 | migrations.RunPython(fix_null_locales, reverse_code=migrations.RunPython.noop), 76 | ] 77 | -------------------------------------------------------------------------------- /myhpi/core/migrations/0002_rootpage.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations 2 | 3 | # Manual migration, based on: 4 | # https://www.accordbox.com/blog/create-wagtail-project/#home 5 | # https://github.com/wagtail/wagtail/tree/v2.11.3/wagtail/project_template/home/migrations 6 | 7 | 8 | def create_homepage(apps, schema_editor): 9 | # Get models 10 | ContentType = apps.get_model("contenttypes.ContentType") 11 | Page = apps.get_model("wagtailcore.Page") 12 | Site = apps.get_model("wagtailcore.Site") 13 | RootPage = apps.get_model("core.RootPage") 14 | Footer = apps.get_model("core.Footer") 15 | 16 | # Delete the default homepage generated in 0002_initial_data of wagtailcore 17 | # If migration is run multiple times, it may have already been deleted 18 | Page.objects.filter(id=2).delete() 19 | 20 | # Create content type for homepage model 21 | homepage_content_type, __ = ContentType.objects.get_or_create( 22 | model="rootpage", app_label="core" 23 | ) 24 | 25 | # Create content type for footer model 26 | footer_content_type, __ = ContentType.objects.get_or_create(model="footer", app_label="core") 27 | 28 | # Create a new homepage 29 | homepage, __ = RootPage.objects.get_or_create( 30 | title="Startseite", 31 | draft_title="myHPI", 32 | slug="home", 33 | content_type=homepage_content_type, 34 | path="00010001", 35 | depth=2, 36 | numchild=0, 37 | url_path="/home/", 38 | is_public=True, 39 | author_visible=False, 40 | body="Willkommen auf myHPI.de!\n\nmyHPI ist eine Webseite von und für Studierende am Hasso-Plattner-Institut (HPI) in Potsdam und für diejenigen, die es noch werden wollen. Auf dieser Seite werden verschiedene Informationen rund um das Studium am HPI zur Verfügung gestellt. Der Fachschaftsrat Digital Engineering betreibt die Webseite und veröffentlicht hier unter anderem seine Sitzungsprotokolle.\n\nNicht alle Informationen sind öffentlich zugänglich, für einige Bereiche der Webseite ist ein HPI-Login notwendig.", 41 | ) 42 | 43 | # Create a site with the new homepage set as the root 44 | Site.objects.get_or_create( 45 | hostname="localhost", 46 | root_page=homepage, 47 | is_default_site=True, 48 | ) 49 | 50 | Footer.objects.get_or_create( 51 | column_1="# Fachschaft\r\n\r\n- [Twitter](https://twitter.com/fachschaftsrat)\r\n- [Discord](https://discord.com)\r\n- [Telegram](https://telegram.org)", 52 | column_2="# Rechtliches\r\n\r\n- [Impressum]()\r\n- [Datenschutzerklärung]()", 53 | column_3="# Entwicklung\r\n\r\n- [GitHub](https://github.com/fsr-de/myHPI/)\r\n[MYHPI-VERSION]", 54 | column_4="# Sprache\r\n\r\n", 55 | ) 56 | 57 | 58 | class Migration(migrations.Migration): 59 | run_before = [ 60 | ("wagtailcore", "0053_locale_model"), 61 | ] 62 | 63 | dependencies = [ 64 | ("core", "0001_initial"), 65 | ] 66 | 67 | operations = [ 68 | migrations.RunPython(create_homepage), 69 | ] 70 | -------------------------------------------------------------------------------- /myhpi/core/templatetags/core_extras.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django import template 4 | from django.template import Context, Template 5 | 6 | from myhpi.core.markdown.utils import render_markdown 7 | from myhpi.core.models import Footer 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.inclusion_tag("nav_level.html", takes_context=True) 13 | def build_nav_level_for(context, parent_page, level=0, parent_id="root"): 14 | return { 15 | "level_pages": context["pages_by_parent"][ 16 | parent_page.path 17 | ], # all pages to display on this level 18 | "level": level, 19 | "parent_id": parent_id, 20 | "pages_by_parent": context[ 21 | "pages_by_parent" 22 | ], # lookup by parent page for all pages visible for the user 23 | } 24 | 25 | 26 | @register.filter() 27 | def get_nav_children_for(pages_by_parent, parent_path): 28 | return pages_by_parent[parent_path] 29 | 30 | 31 | @register.inclusion_tag("footer.html") 32 | def insert_footer(page): 33 | footer = Footer.objects.first() 34 | return {"footer_columns": [footer.column_1, footer.column_2, footer.column_3], "page": page} 35 | 36 | 37 | @register.filter() 38 | def hasTocContent(toc): 39 | return "li" in toc 40 | 41 | 42 | @register.filter(name="tag_external_links") 43 | def tag_external_links(content): 44 | """Takes the content of a website and inserts external link icons after every external link.""" 45 | external_links = re.finditer( 46 | # Matches any tags (and any tags inside it) which href does not start with # (anchors) or /a (relative to site root). 47 | # The SITE_URL is not included in internal links inserted via the editor: [Home](page:3) => Home 48 | r']*href="(?!#|/\w)[^>]*>(.*?)', 49 | content, 50 | ) 51 | for link in reversed(list(external_links)): 52 | content = ( 53 | content[: link.start() + len("")] 56 | + " {% bs_icon 'box-arrow-up-right' extra_classes='external-link-icon' %}" 57 | + content[link.end() - len("") :] 58 | ) 59 | template = Template("{% load bootstrap_icons %}" + content) 60 | return template.render(Context()) 61 | 62 | 63 | @register.filter(name="touchify_abbreviations") 64 | def touchify_abbreviations(content): 65 | abbreviations = re.finditer("]*>[^<]*", content) 66 | for link in reversed(list(abbreviations)): 67 | content = content[: link.start() + 5] + " tabindex=0" + content[link.start() + 5 :] 68 | template = Template("{% load bootstrap_icons %}" + content) 69 | return template.render(Context()) 70 | 71 | 72 | @register.filter(name="markdown") 73 | def markdown(value): 74 | return render_markdown(value) 75 | 76 | 77 | @register.filter(name="get_link_for_group") 78 | def get_link_for_group(minutes_creation_links, group): 79 | return minutes_creation_links[group.id] 80 | 81 | 82 | @register.filter(name="non_empty") 83 | def toc_non_empty(toc): 84 | toc_links = re.findall(r' 0 86 | -------------------------------------------------------------------------------- /myhpi/tests/core/test_view_permissions.py: -------------------------------------------------------------------------------- 1 | from myhpi.tests.core.utils import MyHPIPageTestCase 2 | 3 | 4 | class ViewPermissionTests(MyHPIPageTestCase): 5 | def test_unauthorized_user_can_view_public_page(self): 6 | response = self.client.get(self.public_page.url, follow=True) 7 | self.assertEqual(response.status_code, 200) 8 | 9 | def test_unauthorized_user_can_not_view_common_page(self): 10 | response = self.client.get(self.common_page.url, follow=True) 11 | self.assertEqual(response.status_code, 403) 12 | 13 | def test_unauthorized_user_can_not_view_private_page(self): 14 | response = self.client.get(self.private_page.url, follow=True) 15 | self.assertEqual(response.status_code, 403) 16 | 17 | def test_student_can_view_public_page(self): 18 | self.sign_in_as_student() 19 | response = self.client.get(self.public_page.url, follow=True) 20 | self.assertEqual(response.status_code, 200) 21 | 22 | def test_student_can_view_common_page(self): 23 | self.sign_in_as_student() 24 | response = self.client.get(self.common_page.url, follow=True) 25 | self.assertEqual(response.status_code, 200) 26 | 27 | def test_student_can_not_view_private_page(self): 28 | self.sign_in_as_student() 29 | response = self.client.get(self.private_page.url, follow=True) 30 | self.assertEqual(response.status_code, 403) 31 | 32 | def test_student_representative_can_view_public_page(self): 33 | self.sign_in_as_student_representative() 34 | response = self.client.get(self.public_page.url, follow=True) 35 | self.assertEqual(response.status_code, 200) 36 | 37 | def test_student_representative_can_view_common_page(self): 38 | self.sign_in_as_student_representative() 39 | response = self.client.get(self.common_page.url, follow=True) 40 | self.assertEqual(response.status_code, 200) 41 | 42 | def test_student_representative_can_view_private_page(self): 43 | self.sign_in_as_student_representative() 44 | response = self.client.get(self.private_page.url, follow=True) 45 | self.assertEqual(response.status_code, 200) 46 | 47 | def test_super_user_can_view_all_pages(self): 48 | self.sign_in_as_super_user() 49 | response = self.client.get(self.public_page.url, follow=True) 50 | self.assertEqual(response.status_code, 200) 51 | response = self.client.get(self.common_page.url, follow=True) 52 | self.assertEqual(response.status_code, 200) 53 | response = self.client.get(self.private_page.url, follow=True) 54 | self.assertEqual(response.status_code, 200) 55 | 56 | def test_document_view(self): 57 | self.common_page.attachments.add(self.first_document) 58 | self.common_page.save() 59 | self.private_page.attachments.add(self.second_document) 60 | self.private_page.save() 61 | 62 | self.sign_in_as_student() 63 | response = self.client.get(self.first_document.url, follow=True) 64 | self.assertEqual(response.status_code, 200) 65 | 66 | response = self.client.get(self.second_document.url, follow=True) 67 | self.assertEqual(response.status_code, 403) 68 | -------------------------------------------------------------------------------- /myhpi/core/templates/core/sidebar.html: -------------------------------------------------------------------------------- 1 | {% load core_extras %} 2 | {% load i18n %} 3 | 4 | {% if perms.wagtail.edit_page %} 5 | {% if page.has_unpublished_changes %} 6 | 11 | {% endif %} 12 | {% with page.visible_for.all|join:", " as visibility %} 13 | {% if visibility %} 14 | 18 | {% endif %} 19 | {% endwith %} 20 | {% endif %} 21 | 22 | 24 | 44 | {% if parsed_md.1|hasTocContent or page.attachments.exists %} 45 | 77 | {% endif %} 78 | -------------------------------------------------------------------------------- /myhpi/search/templates/search/search.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load static wagtailcore_tags %} 3 | {% load i18n %} 4 | 5 | {% block body_class %}template-searchresults{% endblock %} 6 | 7 | {% block title %} 8 | {% translate "Search" %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 |

    13 | {% translate "Search" %} 14 |

    15 | 16 | {% include "search/search_field.html" %} 17 | 18 | {% if search_results_page %} 19 | 20 | 21 | {% if search_results_page.paginator.count == 1 %} 22 | {% translate "One result found." %} 23 | {% else %} 24 | {{ search_results_page.paginator.count }}{% blocktrans %} results found.{% endblocktrans %} 25 | {% endif %} 26 | 27 | 28 |
      29 | {% for result in search_results_page %} 30 | {% include "search/search_result.html" %} 31 | {% endfor %} 32 |
    33 | 34 | 74 | 75 | {% elif search_query %} 76 | {% translate "No results found." %} 77 | {% endif %} 78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /myhpi/tests/polls/test_ranked_choice_localization.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | 3 | from wagtail.models import Locale 4 | 5 | from myhpi.polls.models import PollList, RankedChoiceOption, RankedChoicePoll 6 | from myhpi.tests.core.utils import MyHPIPageTestCase, ensure_ancestors_translated 7 | 8 | 9 | def create_ranked_choice_poll_with_translation(base_poll, locale_code="de"): 10 | de_locale, _ = Locale.objects.get_or_create(language_code=locale_code) 11 | ensure_ancestors_translated(base_poll, de_locale) 12 | translated_poll = base_poll.copy_for_translation(de_locale) 13 | translated_poll.title = base_poll.title + " (DE)" 14 | translated_poll.save() 15 | for option in base_poll.options.all(): 16 | option.__class__.objects.create( 17 | name=option.name + " (DE)", 18 | description=option.description, 19 | poll=translated_poll, 20 | ) 21 | return translated_poll 22 | 23 | 24 | class RankedChoicePollLocalizationTests(MyHPIPageTestCase): 25 | def setUp(self): 26 | super().setUp() 27 | self.poll_list = PollList( 28 | title="Polls", 29 | slug="polls", 30 | path="0001000200010005", 31 | depth=4, 32 | is_public=True, 33 | ) 34 | self.information_menu.add_child(instance=self.poll_list) 35 | self.poll = RankedChoicePoll( 36 | title="SLASH 1999", 37 | slug="slash-1999", 38 | description="Who should win the SLASH 1999?", 39 | start_date=datetime.now() - timedelta(days=1), 40 | end_date=datetime.now() + timedelta(days=1), 41 | eligible_groups=[self.test_data["groups"][0]], 42 | results_visible=True, 43 | visible_for=[self.test_data["groups"][0]], 44 | is_public=True, 45 | ) 46 | self.poll_list.add_child(instance=self.poll) 47 | self.option_alice = RankedChoiceOption.objects.create(name="Alice", poll=self.poll) 48 | self.option_bob = RankedChoiceOption.objects.create(name="Bob", poll=self.poll) 49 | self.translated_poll = create_ranked_choice_poll_with_translation(self.poll) 50 | 51 | def test_vote_affects_canonical_poll(self): 52 | self.sign_in_as_student() 53 | # Vote via translated poll, but use canonical poll's option IDs 54 | canonical_option = self.option_alice 55 | # Provide both options to ensure form is valid 56 | form_data = {f"option_{self.option_alice.pk}": 1, f"option_{self.option_bob.pk}": 2} 57 | self.client.post(self.translated_poll.url, data=form_data, follow=True) 58 | self.assertEqual(self.poll.already_voted.count(), 1) 59 | self.assertFalse(self.translated_poll.already_voted.exists()) 60 | 61 | def test_cannot_vote_twice_in_different_locales(self): 62 | self.sign_in_as_student() 63 | # Vote in canonical poll 64 | form_data = {f"option_{self.option_alice.pk}": 1, f"option_{self.option_bob.pk}": 2} 65 | self.client.post(self.poll.url, data=form_data, follow=True) 66 | # Try to vote in translated poll, use canonical poll's option IDs 67 | form_data = {f"option_{self.option_alice.pk}": 1, f"option_{self.option_bob.pk}": 2} 68 | response = self.client.post(self.translated_poll.url, data=form_data, follow=True) 69 | self.assertContains(response, "Du darfst nicht abstimmen", status_code=200) 70 | self.assertEqual(self.poll.already_voted.count(), 1) 71 | 72 | def test_results_aggregated_in_canonical_poll(self): 73 | self.sign_in_as_student() 74 | form_data = {f"option_{self.option_alice.pk}": 1, f"option_{self.option_bob.pk}": 2} 75 | self.client.post(self.poll.url, data=form_data, follow=True) 76 | # Results in translated poll should reflect canonical poll 77 | self.assertEqual(self.poll.ballots.count(), 1) 78 | self.assertEqual(self.translated_poll.ballots.count(), 0) 79 | -------------------------------------------------------------------------------- /myhpi/static/js/myHPI.js: -------------------------------------------------------------------------------- 1 | /* Settings */ 2 | 3 | const isMobileLayoutActive = () => { 4 | return window.innerWidth < 1200 5 | } 6 | 7 | const numberOfSupportedLevels = 3 8 | const defaultPagePadding = 1.5 9 | const navbarBarHeight = 0.3 10 | let previousScrollPosition = window.scrollY 11 | 12 | /* Logic */ 13 | 14 | const enableLogout = () => { 15 | document.querySelector("#logout-link").onclick = () => { 16 | document.querySelector("#logout-form").submit() 17 | } 18 | } 19 | 20 | /** 21 | * Toggles whether the given element prevents an ancestor from being hidden when scrolling down. 22 | * 23 | * If the element has the class `block-ancestor-hide`, any ancestor may not be hidden when scrolling down. 24 | * 25 | * @param {Node} element Node to toggle the prevention on. 26 | */ 27 | const toggleHideOnScrollBlock = (element) => { 28 | element.classList.toggle("block-ancestor-hide") 29 | } 30 | 31 | /** 32 | * Hides all nodes with the class `xl-hide-on-scroll` when scrolling down. 33 | * When scrolling up, the nodes are displayed again. 34 | * 35 | * Exception: Elements having a descendant with the class `block-ancestor-hide` will always be displayed. 36 | * 37 | * @param {number} minScrollPosition The hide/show behaviour will only be activated after scrolling past this position. 38 | * Before, the elements will always be displayed. 39 | */ 40 | const toggleElementVisibilityOnScroll = (minScrollPosition = 0) => { 41 | let currentScrollPosition = window.scrollY 42 | let elements = document.querySelectorAll(".xl-hide-on-scroll") 43 | if ( 44 | previousScrollPosition < currentScrollPosition && 45 | currentScrollPosition > minScrollPosition 46 | ) { 47 | elements.forEach((el) => { 48 | if (el.querySelector(".block-ancestor-hide")) return 49 | el.classList.add("hide-now") 50 | }) 51 | } else { 52 | elements.forEach((el) => el.classList.remove("hide-now")) 53 | } 54 | previousScrollPosition = currentScrollPosition 55 | } 56 | 57 | const localizeLastPublished = () => { 58 | const lastPublished = document.getElementById("last-published") 59 | const timezone_server = lastPublished.getAttribute("title") 60 | const timezone_user = Intl.DateTimeFormat().resolvedOptions().timeZone 61 | 62 | if (timezone_server !== timezone_user) { 63 | lastPublishedLocalized = new Date( 64 | lastPublished.getAttribute("datetime"), 65 | ).toLocaleString(undefined, { 66 | year: "numeric", 67 | month: "numeric", 68 | day: "numeric", 69 | hour: "numeric", 70 | minute: "2-digit", 71 | timeZoneName: "short", 72 | }) 73 | 74 | // replace