diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index fb7547f9..00000000 --- a/.coveragerc +++ /dev/null @@ -1,12 +0,0 @@ -[run] -omit = - */migrations/* - */tests.py - */tests_*.py - */tests/* - venv/* - -source = . -[report] -precision = 2 -show_missing = True diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5455e764 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +latex/anschreiben/veranstalter.adr +# Ignore all files in db folder +db/* +# But keep the folder +!db/.gitkeep + +*.pyc +htmlcov +pyenv +*.aux +*.log +.idea +*.pdf +*.adr +.project +.pydevproject +.settings/* +.coverage +settings_secret.py +static/* +venv/ +.venv +.vscode/* +.python-version + +node_modules/ + +*.mo + +.env + +*.swp \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..2c94e7e4 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +DJANGO_SETTINGS_MODULE=settings.prod +ALLOWED_HOSTS=.fachschaft.informatik.tu-darmstadt.de,.d120.de +CSRF_TRUSTED_ORIGINS= +SECRET_KEY= +KEYCLOAK_CLIENT_ID= +KEYCLOAK_SECRET= +KEYCLOAK_SERVER_URL= +EMAIL_HOST= +EMAIL_PORT= +EMAIL_USE_TLS=True +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +GUNICORN_WORKERS= \ No newline at end of file diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml index e7463d29..19cd5cda 100644 --- a/.github/workflows/django.yml +++ b/.github/workflows/django.yml @@ -21,7 +21,7 @@ jobs: run: | python -m pip install --upgrade pip pip install coveralls - pip install -r requirements.txt + pip install -e ".[dev]" echo "PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher',]" >> src/settings.py - name: Static tests diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 00000000..ee352d5f --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,13 @@ +name: Docker Build + +on: [push, pull_request] + +jobs: + docker-build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Build Docker image + run: docker build -t pyfeedback:test . \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8e614510..9eab4f7e 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,14 @@ pyenv settings_secret.py static/* venv/ +.venv/ .vscode/* .python-version node_modules/ + +*.mo + +.env + +*.swp \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..929a8ab5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,76 @@ +# ========================================== +# Build Node.js Dependencies +# ========================================== + +FROM node:22-slim AS node-builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --only=production + +# ========================================== +# Build Python Dependencies +# ========================================== + +FROM python:3.13-slim AS python-builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY pyproject.toml ./ + +COPY src ./src + +RUN pip install --root-user-action ignore --upgrade pip && \ + pip install --root-user-action ignore --no-cache-dir . + +# ========================================== +# Final Production Image +# ========================================== + +FROM python:3.13-slim AS production + +ARG DEBIAN_FRONTEND=noninteractive + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + gettext \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# non-root user + +RUN useradd -m -r appuser + +WORKDIR /app + +COPY --from=python-builder /usr/local/lib/python3.13/site-packages/ /usr/local/lib/python3.13/site-packages/ +COPY --from=python-builder /usr/local/bin/ /usr/local/bin/ + +COPY --from=node-builder /app/node_modules /app/node_modules + +COPY --chown=appuser:appuser . . + +COPY --chown=appuser:appuser entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +RUN mkdir -p /app/static && \ + chown appuser:appuser /app/static + +# Build static assets & translations as non-root user + +USER appuser + +RUN django-admin compilemessages && \ + python src/manage.py collectstatic --no-input --clear + +EXPOSE 8000 +WORKDIR /app/src + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] \ No newline at end of file diff --git a/Readme.md b/Readme.md index ecea5d74..e4d6f3dd 100644 --- a/Readme.md +++ b/Readme.md @@ -1,5 +1,6 @@ # Feedback-Software -![Build Status](https://github.com/d120/pyfeedback/workflows/Test/badge.svg?branch=master) +[![Test](https://github.com/d120/pyfeedback/actions/workflows/django.yml/badge.svg?branch=master)](https://github.com/d120/pyfeedback/actions/workflows/django.yml) +[![Docker Build](https://github.com/d120/pyfeedback/actions/workflows/docker-build.yml/badge.svg?branch=master)](https://github.com/d120/pyfeedback/actions/workflows/docker-build.yml) [![Coverage Status](https://coveralls.io/repos/github/d120/pyfeedback/badge.svg?branch=master)](https://coveralls.io/github/d120/pyfeedback?branch=master) @@ -16,17 +17,34 @@ To use pyfeedback the following tools have to be installed: ## Preparing development environment -* Create a virtualenv with `python -m venv venv` -* Activate the virtualenv with `source venv/bin/activate` -* Install all requirements with `pip install -r requirements.txt` +* Create a virtualenv with `python -m venv .venv` +* Activate the virtualenv with `source .venv/bin/activate` +* Install all requirements with `pip install -e ".[dev]"` * Create the test database with `python src/manage.py migrate` * Compile translations with `(cd src && django-admin compilemessages)` * Install frontend dependencies with `npm i` * Start the development server with `python src/manage.py runserver` +## Production +- Use `pip install .` to install dependencies. +- Docker: make sure to set `DJANGO_SETTINGS_MODULE=settings.prod` in .env +- Without docker: `gunicorn wsgi:application` + ## Tests pyfeedback is using a test driven development and tries to get to 100% coverage. Tests can be run with ``` python src/manage.py test feedback ``` Do not implement new functionality without providing a test for it. + +## Settings + +`src/manage.py` uses settings.dev, `src/wsgi.py` uses settings.prod + +Use `python src/manage.py runserver --settings=settings.prod` to run production settings during development. + + +## Docker + +- DJANGO_SETTINGS_MODULE: `settings.prod` or `settings.dev`. `settings.prod` runs with wsgi.py and gunicorn, `settings.dev` with manage.py +- GUNICORN_WORKERS: *default* 3 workers diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..8320c1b9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + pyfeedback: + build: . + container_name: pyfeedback-docker + ports: + - "8000:8000" + env_file: + - .env + volumes: + - pyfeedback-db:/app/db/ + +volumes: + pyfeedback-db: diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..b1e18774 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +# Exit on error +set -e + +echo "Applying database migrations..." +python manage.py migrate --no-input + +if [ $# -eq 0 ]; then + + if [ "$DJANGO_SETTINGS_MODULE" = "settings.dev" ]; then + echo "Starting Django development server..." + exec python manage.py runserver 0.0.0.0:8000 + + elif [ "$DJANGO_SETTINGS_MODULE" = "settings.prod" ]; then + echo "Starting Gunicorn with ${GUNICORN_WORKERS:-3} workers..." + exec gunicorn \ + --bind 0.0.0.0:8000 \ + --workers "${GUNICORN_WORKERS:-3}" \ + wsgi:application + + else + echo "Unknown DJANGO_SETTINGS_MODULE: $DJANGO_SETTINGS_MODULE" + exit 1 + fi + +else + echo "Running custom command: $@" + exec "$@" +fi diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..6c2d67c9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyfeedback" +version = "3.0.0" +description = "Web application to assist feedback for modules at TU Darmstadt" +readme = "README.md" +requires-python = ">=3.13" +authors = [ + { name = "d120" } +] +dependencies = [ + "Django==6.0.7", + "django-formtools==2.7", + "django-allauth[socialaccount]==65.18.0", + "gunicorn==26.0.0", + "whitenoise[brotli]==6.12.0", +] + +[project.optional-dependencies] +dev = [ + "django-debug-toolbar==7.0.0", + "coverage==7.15.1", + "freezegun==1.5.5", + "python-dotenv>=1.0.0", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.coverage.run] +source = ["src"] +omit = [ + "*/migrations/*", + "*/tests/*", + "*/tests.py", + "*/tests_*.py", + "src/manage.py", + "src/wsgi.py", + "src/asgi.py", + "*/locale/*", + "*/settings/*", +] + +[tool.coverage.report] +precision = 2 +show_missing = true +fail_under = 85 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 85a2141d..00000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -coverage==7.15.1 -Django==6.0.7 -django-debug-toolbar==7.0.0 -freezegun==1.5.5 -django-formtools==2.7 -django-allauth[socialaccount]==65.18.0 diff --git a/script/bootstrap b/script/bootstrap deleted file mode 100755 index 9a54ec5e..00000000 --- a/script/bootstrap +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -set -e - -# set environment variable when we want to update a production instance -if [ "$1" = "--prod" ]; then - export DJANGO_SETTINGS_MODULE=settings_production -fi - -virtualenv -p python3 ../virtualenv -source ../virtualenv/bin/activate -pip install -r requirements.txt -./src/manage.py migrate -./src/manage.py collectstatic - -if [ "$2" == "--pass" ]; then - ./src/manage.py shell -c "from django.contrib.auth.models import User; User.objects.create_superuser('admin', 'admin@example.com', '"$3"')" -else - echo "Creating super user, enter credentials:" - ./src/manage.py createsuperuser -fi - -deactivate - -echo -echo "environment successfully bootstrapped. Start a server with script/server" diff --git a/src/feedback/templatetags/translate_url.py b/src/feedback/templatetags/translate_url.py index b8c95403..7c3ef4a2 100644 --- a/src/feedback/templatetags/translate_url.py +++ b/src/feedback/templatetags/translate_url.py @@ -6,25 +6,29 @@ @register.simple_tag(takes_context=True) def translate_url(context, language): - ''' - used to translate urls for switching languages - ''' - if 'request' in context : # during some errors this prevents server error - try: - view = resolve(context['request'].path_info) - except Resolver404: - return "" + """ + Used to translate URLs for switching languages. + """ + if "request" not in context: + return "" + + try: + view = resolve(context["request"].path_info) + except Resolver404: + return "" - request_language = translation.get_language() + request_language = translation.get_language() + + try: translation.activate(language) namespace = view.namespace view_name = f"{namespace}:{view.url_name}" if namespace else view.url_name - - url = reverse(view_name, args=view.args, kwargs=view.kwargs) - + + return reverse( + view_name, + args=view.args, + kwargs=view.kwargs, + ) + finally: translation.activate(request_language) - return url - else : - return "" - \ No newline at end of file diff --git a/src/feedback/tests/.test_views_veranstalter.py.swp b/src/feedback/tests/.test_views_veranstalter.py.swp deleted file mode 100644 index bbcce0d7..00000000 Binary files a/src/feedback/tests/.test_views_veranstalter.py.swp and /dev/null differ diff --git a/src/feedback/tests/test_adapters.py b/src/feedback/tests/test_adapters.py new file mode 100644 index 00000000..56419cb8 --- /dev/null +++ b/src/feedback/tests/test_adapters.py @@ -0,0 +1,78 @@ +from unittest.mock import MagicMock +from django.test import TestCase, RequestFactory +from django.contrib.auth.models import User + +from feedback.auth_adapter import FeedbackSocialAccountAdapter + +class FeedbackSocialAccountAdapterTests(TestCase): + + def setUp(self): + self.factory = RequestFactory() + self.request = self.factory.get("/") + self.adapter = FeedbackSocialAccountAdapter() + + def test_pre_social_login_with_existing_user_by_username(self): + """ + Unsaved social user matches an existing username in DB. + Should connect to existing user and elevate permissions. + """ + existing_user = User.objects.create_user( + username="johndoe", + email="john@example.com", + is_staff=False, + is_superuser=False, + ) + + unsaved_user = User(username="johndoe", email="john@example.com") + + mock_sociallogin = MagicMock() + mock_sociallogin.user = unsaved_user + + self.adapter.pre_social_login(self.request, mock_sociallogin) + + mock_sociallogin.connect.assert_called_once_with(self.request, existing_user) + + existing_user.refresh_from_db() + self.assertTrue(existing_user.is_staff) + self.assertTrue(existing_user.is_superuser) + + def test_pre_social_login_with_new_user(self): + """ + Unsaved social user does NOT match any username in DB. + Should save the new user and elevate permissions. + """ + unsaved_user = User(username="newuser", email="new@example.com") + + mock_sociallogin = MagicMock() + mock_sociallogin.user = unsaved_user + + self.adapter.pre_social_login(self.request, mock_sociallogin) + + mock_sociallogin.connect.assert_not_called() + + db_user = User.objects.get(username="newuser") + self.assertIsNotNone(db_user.id) + self.assertTrue(db_user.is_staff) + self.assertTrue(db_user.is_superuser) + + def test_pre_social_login_with_already_saved_user(self): + """ + Social user already has an ID (already saved/authenticated). + Should skip user lookup/creation and elevate permissions. + """ + saved_user = User.objects.create_user( + username="saveduser", + is_staff=False, + is_superuser=False, + ) + + mock_sociallogin = MagicMock() + mock_sociallogin.user = saved_user + + self.adapter.pre_social_login(self.request, mock_sociallogin) + + mock_sociallogin.connect.assert_not_called() + + saved_user.refresh_from_db() + self.assertTrue(saved_user.is_staff) + self.assertTrue(saved_user.is_superuser) \ No newline at end of file diff --git a/src/feedback/tests/test_commands.py b/src/feedback/tests/test_commands.py new file mode 100644 index 00000000..840e006c --- /dev/null +++ b/src/feedback/tests/test_commands.py @@ -0,0 +1,78 @@ +from io import StringIO +from datetime import timedelta +from unittest.mock import patch + +from django.test import TestCase +from django.core.management import call_command +from django.utils import timezone + +from feedback.models import EmailChange + + +class CleanupEmailChangeReqCommandTests(TestCase): + + def setUp(self): + """Set up test instances for different statuses and dates.""" + now = timezone.now() + + # 1. EXPIRED: > 7 days old -> should be deleted + self.expired_old = EmailChange.objects.create( + status=EmailChange.Status.EXPIRED, + dynamic_expiry_time=now - timedelta(days=8), + ) + + # 2. EXPIRED: < 7 days old -> should not be deleted + self.expired_recent = EmailChange.objects.create( + status=EmailChange.Status.EXPIRED, + dynamic_expiry_time=now - timedelta(days=5), + ) + + # 3. MAGIC_LINK_SENT: > 30 days old -> should be deleted + self.link_sent_old = EmailChange.objects.create( + status=EmailChange.Status.MAGIC_LINK_SENT, + dynamic_expiry_time=now - timedelta(days=31), + ) + + # 4. OTP_SENT: > 30 days old -> should be deleted + self.otp_sent_old = EmailChange.objects.create( + status=EmailChange.Status.OTP_SENT, + dynamic_expiry_time=now - timedelta(days=32), + ) + + # 5. MAGIC_LINK_SENT: < 30 days old -> should not be deleted + self.link_sent_recent = EmailChange.objects.create( + status=EmailChange.Status.MAGIC_LINK_SENT, + dynamic_expiry_time=now - timedelta(days=15), + ) + + @patch("feedback.management.commands.cleanup_email_change_req.logger") + def test_command_deletes_eligible_records(self, mock_logger): + """Test deleting eligible records (hits if branch: deleted_count > 0).""" + out = StringIO() + + call_command("cleanup_email_change_req", stdout=out) + + output = out.getvalue() + + self.assertIn("Cleanup successful: Deleted 3 EmailChange records.", output) + mock_logger.info.assert_called_once_with( + "Cleanup successful: Deleted 3 EmailChange records." + ) + + remaining_ids = set(EmailChange.objects.values_list("id", flat=True)) + self.assertEqual(remaining_ids, {self.expired_recent.id, self.link_sent_recent.id}) + + @patch("feedback.management.commands.cleanup_email_change_req.logger") + def test_command_when_no_records_eligible(self, mock_logger): + """Test behavior when no records match (hits else branch: deleted_count == 0).""" + EmailChange.objects.all().delete() + + out = StringIO() + call_command("cleanup_email_change_req", stdout=out) + + output = out.getvalue() + + self.assertIn("Cleanup run: No records were eligible for deletion.", output) + mock_logger.info.assert_called_once_with( + "Cleanup run: No records were eligible for deletion." + ) \ No newline at end of file diff --git a/src/feedback/tests/test_parser_ergebnisse.py b/src/feedback/tests/test_parser_ergebnisse.py index 4ebcc8a2..8859aaee 100644 --- a/src/feedback/tests/test_parser_ergebnisse.py +++ b/src/feedback/tests/test_parser_ergebnisse.py @@ -3,7 +3,7 @@ from django.conf import settings from django.test import TestCase -from feedback.models import Semester, Veranstaltung, Fragebogen2008, Fragebogen2009, Fragebogen2012, Fragebogen2016, Fragebogen2025 +from feedback.models import Semester, Veranstaltung, Fragebogen2008, Fragebogen2009, Fragebogen2012, Fragebogen2016, Fragebogen2020, Fragebogen2025 from feedback.parser.ergebnisse import parse_ergebnisse from feedback.parser.ergebnisse.parser import Parser @@ -79,5 +79,8 @@ def test_parse_ergebnisse2012(self): def test_parse_ergebnisse2016(self): self._do_parse_ergebnisse_test(20155, '2016', Fragebogen2016) + def test_parse_ergebnisse2020(self): + self._do_parse_ergebnisse_test(20155, '2020', Fragebogen2020) + def test_parse_ergebnisse2025(self): self._do_parse_ergebnisse_test(20255, '2025', Fragebogen2025) diff --git a/src/feedback/tests/test_templatetags.py b/src/feedback/tests/test_templatetags.py new file mode 100644 index 00000000..a094c9b3 --- /dev/null +++ b/src/feedback/tests/test_templatetags.py @@ -0,0 +1,119 @@ +from django.http import HttpResponse +from django.test import RequestFactory, SimpleTestCase, override_settings +from django.urls import include, path +from django.utils.translation import gettext_lazy as _ +from django.utils import translation +from django.conf.urls.i18n import i18n_patterns + +from feedback.templatetags.filter_range import filter_range +from feedback.templatetags.translate_url import translate_url + + +class FilterRangeTests(SimpleTestCase): + def test_returns_range_as_list(self): + self.assertEqual(filter_range(0, 5), [0, 1, 2, 3, 4]) + + def test_start_is_inclusive(self): + self.assertEqual(filter_range(2, 5), [2, 3, 4]) + + def test_end_is_exclusive(self): + self.assertEqual(filter_range(2, 5), [2, 3, 4]) + self.assertNotIn(5, filter_range(2, 5)) + + def test_empty_range(self): + self.assertEqual(filter_range(5, 5), []) + + def test_reverse_range(self): + self.assertEqual(filter_range(5, 2), []) + + def test_negative_numbers(self): + self.assertEqual(filter_range(-2, 3), [-2, -1, 0, 1, 2]) + + +# URL namespace used by translate_url tests. +def test_view(request, pk=None): + return HttpResponse("OK") + +urlpatterns = [ + path( + "", + include( + ( + [ + path( + "product//", + test_view, + name="detail", + ), + ], + "testapp", + ), + namespace="products", + ), + ), +] + +@override_settings(ROOT_URLCONF=__name__) +class TranslateUrlTests(SimpleTestCase): + def setUp(self): + self.factory = RequestFactory() + translation.activate("en") + + def tearDown(self): + translation.deactivate() + + def test_returns_empty_string_when_request_is_missing(self): + context = {} + + result = translate_url(context, "de") + + self.assertEqual(result, "") + + def test_returns_empty_string_for_unresolvable_url(self): + request = self.factory.get("/does-not-exist/") + + context = { + "request": request, + } + + result = translate_url(context, "de") + + self.assertEqual(result, "") + + def test_translates_url(self): + request = self.factory.get("/product/123/") + + context = { + "request": request, + } + + result = translate_url(context, "de") + + self.assertEqual(result, "/product/123/") + + def test_preserves_original_language(self): + translation.activate("en") + + request = self.factory.get("/product/123/") + + context = { + "request": request, + } + + translate_url(context, "de") + + self.assertEqual( + translation.get_language(), + "en", + ) + + def test_uses_url_kwargs(self): + request = self.factory.get("/product/42/") + + context = { + "request": request, + } + + result = translate_url(context, "de") + + self.assertEqual(result, "/product/42/") \ No newline at end of file diff --git a/src/feedback/tests/test_tools.py b/src/feedback/tests/test_tools.py index fa911535..538a4d7b 100644 --- a/src/feedback/tests/test_tools.py +++ b/src/feedback/tests/test_tools.py @@ -1,11 +1,19 @@ # coding=utf-8 +from django.core import mail from django.template import Context -from django.test import TestCase +from django.test import TestCase, override_settings from feedback.models import Fragebogen2009, Ergebnis2009, Fragebogen2012, Ergebnis2012 from feedback.tests.tools import get_veranstaltung -from feedback.tools import get_average, render_email, ean_checksum_calc, ean_checksum_valid +from feedback.tools import ( + get_average, + render_email, + ean_checksum_calc, + ean_checksum_valid, + send_change_email_link, + send_change_email_otp, +) class GetAverageTest(TestCase): @@ -52,6 +60,7 @@ def test_empty_sheets(self): self.assertSequenceEqual(get_average(Ergebnis2009, self.f[:2], 'v_feedbackpreis'), [None, 0]) +@override_settings(DEFAULT_FROM_EMAIL='test@example.com') class ToolsTest(TestCase): def test_render_email(self): template = 'Die Antwort ist {{op}} {{antwort}}.' @@ -65,3 +74,52 @@ def test_ean_calc(self): self.assertEqual(ean_checksum_calc(200000151700), 0) self.assertEqual(ean_checksum_calc(2000001517000), 0) self.assertTrue(ean_checksum_valid(2000001517000)) + + def test_ean_invalid_length(self): + self.assertFalse(ean_checksum_valid(1234567)) + self.assertFalse(ean_checksum_valid(123456789)) + + def test_ean_invalid_checksum(self): + self.assertFalse(ean_checksum_valid(2000001517001)) + + def test_send_change_email_link(self): + send_change_email_link( + 'new@example.com', + 'https://example.com/change-email/abc', + 15, + ) + + self.assertEqual(len(mail.outbox), 1) + + email = mail.outbox[0] + self.assertEqual( + email.subject, + 'E-Mail-Änderungsanfrage / Email change request', + ) + self.assertEqual(email.from_email, 'test@example.com') + self.assertEqual(email.to, ['new@example.com']) + self.assertIn('new@example.com', email.body) + self.assertIn('https://example.com/change-email/abc', email.body) + self.assertIn('15', email.body) + + def test_send_change_email_otp(self): + send_change_email_otp( + 'new@example.com', + 'old@example.com', + '123456', + 10, + ) + + self.assertEqual(len(mail.outbox), 1) + + email = mail.outbox[0] + self.assertEqual( + email.subject, + 'E-Mail-Änderung OTP / Email change OTP', + ) + self.assertEqual(email.from_email, 'test@example.com') + self.assertEqual(email.to, ['new@example.com']) + self.assertIn('new@example.com', email.body) + self.assertIn('old@example.com', email.body) + self.assertIn('123456', email.body) + self.assertIn('10', email.body) \ No newline at end of file diff --git a/src/locale/de/LC_MESSAGES/django.mo b/src/locale/de/LC_MESSAGES/django.mo deleted file mode 100644 index 21fa752f..00000000 Binary files a/src/locale/de/LC_MESSAGES/django.mo and /dev/null differ diff --git a/src/locale/en/LC_MESSAGES/django.mo b/src/locale/en/LC_MESSAGES/django.mo deleted file mode 100644 index 5e2bf8d3..00000000 Binary files a/src/locale/en/LC_MESSAGES/django.mo and /dev/null differ diff --git a/src/manage.py b/src/manage.py index f9726f9e..b326bd60 100755 --- a/src/manage.py +++ b/src/manage.py @@ -3,7 +3,7 @@ import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev") from django.core.management import execute_from_command_line diff --git a/src/settings/__init__.py b/src/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/settings.py b/src/settings/base.py similarity index 91% rename from src/settings.py rename to src/settings/base.py index 7d119690..47077055 100644 --- a/src/settings.py +++ b/src/settings/base.py @@ -1,15 +1,13 @@ # coding=utf-8 # Django settings for feedback project. -# determine if this is a production system import os import sys import ipaddress from django.utils.translation import gettext_lazy as _ +import importlib.util -DEBUG = True - -# default is False, make True to see exeptions when DEBUG = False +# make True to see exeptions when DEBUG = False DEBUG_PROPAGATE_EXCEPTIONS = False ADMINS = ( @@ -19,7 +17,7 @@ MANAGERS = ADMINS EMAIL_SUBJECT_PREFIX = '' -BASE_PATH = os.path.dirname(os.path.abspath(__file__)) + '/../' +BASE_PATH = os.path.dirname(os.path.abspath(__file__)) + '/../../' DATABASES = { 'default': { @@ -109,17 +107,20 @@ # überschreiben! MIDDLEWARE = [ - 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'feedback.auth.FSDebugRemoteUserMiddleware', 'allauth.account.middleware.AccountMiddleware', - # 'whitenoise.middleware.WhiteNoiseMiddleware', # while DEBUG=False servers static files, Note:first pip install whitenoise + 'whitenoise.middleware.WhiteNoiseMiddleware', ] -if not TESTING: + +HAS_DEBUG_TOOLBAR = importlib.util.find_spec("debug_toolbar") is not None + +if not TESTING and HAS_DEBUG_TOOLBAR : MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] ROOT_URLCONF = 'urls' @@ -158,7 +159,7 @@ 'allauth.socialaccount.providers.openid_connect', ] -if not TESTING: +if not TESTING and HAS_DEBUG_TOOLBAR : INSTALLED_APPS += ['debug_toolbar'] AUTHENTICATION_BACKENDS = ( @@ -202,15 +203,8 @@ 'class': 'django.utils.log.AdminEmailHandler' }, 'console': { - 'level':'DEBUG', - 'class':'logging.StreamHandler', - }, - 'file_all': { 'level': 'INFO', - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': os.path.join(BASE_PATH, 'all.log'), - 'maxBytes': 1024 * 1024 * 5, - 'backupCount': 5, + 'class': 'logging.StreamHandler', 'formatter': 'verbose', }, }, @@ -225,7 +219,7 @@ 'level': 'DEBUG', }, '': { - 'handlers': ['file_all'], + 'handlers': ['console'], 'level': 'INFO', 'propagate': True, }, @@ -279,4 +273,4 @@ # application-specific-cookies CSRF_COOKIE_NAME = 'pyfeedback_csrftoken' -SESSION_COOKIE_NAME = 'pyfeedback_sessionid' +SESSION_COOKIE_NAME = 'pyfeedback_sessionid' \ No newline at end of file diff --git a/src/settings/dev.py b/src/settings/dev.py new file mode 100644 index 00000000..8a4a2243 --- /dev/null +++ b/src/settings/dev.py @@ -0,0 +1,33 @@ +from .base import * + +DEBUG = True + +# Disable secure cookies for local HTTP development +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False + +ALLOWED_HOSTS = [] + +CSRF_TRUSTED_ORIGINS = [] + + +## for testing SSO, set DEBUG=False and ALLOWED_HOSTS = ["*"] +# from dotenv import load_dotenv, find_dotenv + +# load_dotenv(find_dotenv()) + +# SOCIALACCOUNT_PROVIDERS = { +# "openid_connect": { +# "APPS": [ +# { +# "provider_id": "keycloak", +# "name": "Keycloak", +# "client_id": os.getenv("KEYCLOAK_CLIENT_ID"), +# "secret": os.getenv("KEYCLOAK_SECRET"), +# "settings": { +# "server_url": os.getenv("KEYCLOAK_SERVER_URL"), +# }, +# } +# ] +# } +# } diff --git a/src/settings/prod.py b/src/settings/prod.py new file mode 100644 index 00000000..01a5107d --- /dev/null +++ b/src/settings/prod.py @@ -0,0 +1,57 @@ +import os +from .base import * + +DEBUG = os.getenv("DEBUG", "False").lower() in ("true", "1", "yes") + +ALLOWED_HOSTS = [ + host.strip() for host in os.getenv("ALLOWED_HOSTS", "").split(",") if host.strip() +] + +CSRF_TRUSTED_ORIGINS = [ + origin.strip() for origin in os.getenv("CSRF_TRUSTED_ORIGINS", "").split(",") if origin.strip() +] + +SECRET_KEY = os.getenv("SECRET_KEY") +if not SECRET_KEY and not DEBUG: + raise ValueError("CRITICAL: SECRET_KEY environment variable is missing!") + + +## uncomment lines below to fix isses with prefix "feedback" + +# URL_PREFIX = 'feedback/' +# LOGIN_URL = '/' + URL_PREFIX[:-1] + LOGIN_URL +# LOGIN_REDIRECT_URL = '/' + URL_PREFIX[:-1] + LOGIN_REDIRECT_URL +# ACCOUNT_LOGOUT_REDIRECT_URL = '/' + URL_PREFIX[:-1] + ACCOUNT_LOGOUT_REDIRECT_URL + +# SESSION_COOKIE_PATH = '/feedback' +# CSRF_COOKIE_PATH = SESSION_COOKIE_PATH + +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +SOCIALACCOUNT_PROVIDERS = { + "openid_connect": { + "APPS": [ + { + "provider_id": "keycloak", + "name": "Keycloak", + "client_id": os.getenv("KEYCLOAK_CLIENT_ID"), + "secret": os.getenv("KEYCLOAK_SECRET"), + "settings": { + "server_url": os.getenv("KEYCLOAK_SERVER_URL"), + }, + } + ] + } +} + +# @see https://docs.djangoproject.com/es/1.9/topics/email/ +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = os.getenv("EMAIL_HOST") + +email_port_env = os.getenv("EMAIL_PORT", "").strip() +EMAIL_PORT = int(email_port_env) if email_port_env else 587 + +EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "True").lower() in ("true", "1", "yes") +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") \ No newline at end of file diff --git a/src/settings_production.py b/src/settings_production.py deleted file mode 100644 index ba6f04ea..00000000 --- a/src/settings_production.py +++ /dev/null @@ -1,46 +0,0 @@ -from settings import * -import settings_secret as secrets - -DEBUG = False - -ALLOWED_HOSTS = ['.fachschaft.informatik.tu-darmstadt.de', '.d120.de'] - -SECRET_KEY = secrets.SECRET_KEY - - -URL_PREFIX = 'feedback/' -LOGIN_URL = '/' + URL_PREFIX[:-1] + LOGIN_URL -LOGIN_REDIRECT_URL = '/' + URL_PREFIX[:-1] + LOGIN_REDIRECT_URL -ACCOUNT_LOGOUT_REDIRECT_URL = '/' + URL_PREFIX[:-1] + ACCOUNT_LOGOUT_REDIRECT_URL -SESSION_COOKIE_SECURE = True - -SOCIALACCOUNT_PROVIDERS = { - "openid_connect": { - "APPS": [ - { - "provider_id": "keycloak", - "name": "Keycloak", - "client_id": secrets.KEYCLOAK_CLIENT_ID, - "secret": secrets.KEYCLOAK_SECRET, - "settings": { - "server_url": secrets.KEYCLOAK_SERVER_URL, - }, - } - ] - } -} - -# @see https://docs.djangoproject.com/es/1.9/topics/email/ -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' - -EMAIL_HOST = 'mail.d120.de' -EMAIL_PORT = 587 -EMAIL_USE_TLS = True -EMAIL_HOST_USER = 'pyfeedback' -EMAIL_HOST_PASSWORD = secrets.EMAIL_HOST_PASSWORD - -SESSION_COOKIE_PATH = '/feedback' -SESSION_COOKIE_SECURE = True - -CSRF_COOKIE_PATH = SESSION_COOKIE_PATH -CSRF_COOKIE_SECURE = True diff --git a/src/urls.py b/src/urls.py index fd09af2c..130a3d7c 100644 --- a/src/urls.py +++ b/src/urls.py @@ -1,10 +1,9 @@ # coding=utf-8 -from django.conf.urls import include from django.contrib import admin from django.contrib.auth.models import User -from django.urls import re_path, reverse_lazy, path +from django.urls import include, re_path, reverse_lazy, path from django.conf.urls.i18n import i18n_patterns from django.views.generic.base import RedirectView from django.contrib.staticfiles.urls import staticfiles_urlpatterns @@ -12,6 +11,8 @@ from allauth.account.decorators import secure_admin_login +import importlib.util + # Admin-Seiten konfigurieren # admin.site.unregister((User, Group)) @@ -41,7 +42,9 @@ # don't forget to put sample favicon.ico in static files urlpatterns += [path('favicon.ico', RedirectView.as_view(url='/feedback/static/img/favicon.ico', permanent=True))] -if not settings.TESTING and settings.DEBUG : +HAS_DEBUG_TOOLBAR = importlib.util.find_spec("debug_toolbar") is not None + +if not settings.TESTING and settings.DEBUG and HAS_DEBUG_TOOLBAR: # avoided directly using debug_toolbar.urls(), as importing caused error in django-debug-toolbar v6.0.0 # see https://github.com/django-commons/django-debug-toolbar/issues/2167 urlpatterns += [path("__debug__/", include("debug_toolbar.urls"))] \ No newline at end of file diff --git a/src/django.wsgi b/src/wsgi.py similarity index 61% rename from src/django.wsgi rename to src/wsgi.py index ac4b0cc0..c1e47dc3 100644 --- a/src/django.wsgi +++ b/src/wsgi.py @@ -2,7 +2,6 @@ from django.core.wsgi import get_wsgi_application - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.prod") application = get_wsgi_application()