From f629490292852212939b99955040c43ed4368b92 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=B6rn=20Menne?= Date: Mon, 10 Feb 2025 21:21:26 +0100 Subject: [PATCH] Reintroduce sending mails --- georeport/admin.py | 162 +++++++++++++++++- .../0003_category_close_with_link.py | 18 ++ ..._user_category_users_alter_report_title.py | 23 +++ georeport/models.py | 7 +- georeport/tests.py | 2 +- georeport/urls.py | 6 +- georeport/views.py | 39 +++-- pinpoint_report/settings.py | 5 + 8 files changed, 242 insertions(+), 20 deletions(-) create mode 100644 georeport/migrations/0003_category_close_with_link.py create mode 100644 georeport/migrations/0004_rename_user_category_users_alter_report_title.py diff --git a/georeport/admin.py b/georeport/admin.py index f22a8a1..a23b035 100644 --- a/georeport/admin.py +++ b/georeport/admin.py @@ -1,10 +1,15 @@ # Copyright: (c) 2025, Jörn Menne # GNU General Public License v3.0 (see LICSENE or https://www.gnu.org/license/gpl-3.0.md) -from django.contrib import admin +from django.conf.global_settings import DEFAULT_FROM_EMAIL +from django.contrib import admin, messages from typing import override - +from django.utils.translation import ngettext from georeport.models import Category, Report - +from django.conf import settings +from Crypto.Cipher import ChaCha20 +from base64 import urlsafe_b64encode +from django.shortcuts import reverse +from django.core.mail import send_mail # TODO: CategoryAdmin @@ -27,15 +32,162 @@ class CategoryAdmin(admin.ModelAdmin): the admin site, such that the model can be edited on there. """ - exclude = ["user", "groups"] + # exclude = ["users", "groups"] inlines = [CategoryInline] search_fields = ["name"] # TODO: Prevent circles while creating groups + @override + def has_change_permission(self, request, obj=None): + """ + Override has change_permission, in order to only + allow user associated to a category to modify the + category. + The association can be directly (user to category) or + indirectly over groups. + """ + user = request.user + basepermission = super().has_change_permission(request, obj) + # Always allow superusers to modify + if user.is_superuser: + return True + + if obj: + allowed = getAllowedUsers(obj) + else: + allowed = [] + + if basepermission and (request.user in allowed): + return True + return False + + +def getAllowedUsers(category): + """ + Function to get a queryset with all users, which should + have access to the category. + + Arguments: + category: Category + A category, to which allowed users shall be found + + Returns: + Queryset containing all uses associated with the category. + """ + # TODO: Find better location for this function + qs = category.users.all() + for group in category.groups.all(): + qs = qs | group.user_set.all() + + if category.parent: + qs = qs | getAllowedUsers(category.parent) + return qs + # TODO: ReportAdmin @admin.register(Report) class ReportAdmin(admin.ModelAdmin): exclude = [ - "_oldstate", + "_oldState", ] + readonly_fields = [ + "created_at", + "updated_at", + ] + + list_display = ["title", "category__name", "state", "published"] + list_filter = ["state"] + + @admin.action(description="Publish selected reports.") + def make_public(self, request, queryset): + """ + Admin-action to bulk-publish reports. + + Arguments: + request: The current http-request. The request is created by performing the action. + queryset: A queryset containing every Report, which was selected by the user. + + """ + updated = queryset.update(published=True) + self.message_user( + request, + ngettext( + "%d report was published", + "%d reports were published", + updated, + ) + % updated, + messages.SUCCESS, + ) + + @override + def save_model(self, request, obj, form, change): + """ + Override of the default save-function of a ModelAdmin to provide + custom actions before the object is saved. + + For information about the arguments please refer to admin.ModelAdmin.save_model. + """ + # send an update-mail if the state was chagned + if not obj.state == obj._oldState: + send_update(obj) + + # TODO: close__link shall also work on descendants + if obj.state == 1 and obj.category.close_with_link: + send_close_link(obj) + + obj._oldstate = obj.state + super().save_model(request, obj, form, change) + + +def send_update(report): + # TODO: Tests + if not settings.SEND_MAIL: + return + recipient_list = [report.email] + subject = f"Report with title {report.title} was updated." + message = f"The state of the report {report.id}: {report.title} was changed to {report.state}." + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=recipient_list, + fail_silently=True, + ) + + +def send_close_link(report): + """ + If the category allows it, a link is send to the owners of the category, + over which the category can be closed + """ + # TODO: Tests + + if not settings.SEND_MAIL: + return + # Create a encrypted version of the id to send as a close_link + message = "" + padded_id = str(report.id).zfill(6) + cipher = ChaCha20.new(key=settings.KEY) + byte_text = bytes(padded_id, "utf-8") + ciphertext = cipher.encrypt(byte_text) + nonce = cipher.nonce + + b64nonce = urlsafe_b64encode(nonce).decode("utf-8") + b64ct = urlsafe_b64encode(ciphertext).decode("utf-8") + url = reverse("georeport:index") + message = f"localhost:8000{url}{b64nonce}/{b64ct}" + print(message) + + subject = f"Close link for report {report.id}" + user = getAllowedUsers(report.category) + recipient_list = [] + for u in user: + recipient_list.append(u.email) + send_mail( + subject=subject, + message=message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=recipient_list, + fail_silently=True, + ) diff --git a/georeport/migrations/0003_category_close_with_link.py b/georeport/migrations/0003_category_close_with_link.py new file mode 100644 index 0000000..21d9160 --- /dev/null +++ b/georeport/migrations/0003_category_close_with_link.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.5 on 2025-02-10 18:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('georeport', '0002_rename_descrption_report_description_report_latitude_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='category', + name='close_with_link', + field=models.BooleanField(default=False), + ), + ] diff --git a/georeport/migrations/0004_rename_user_category_users_alter_report_title.py b/georeport/migrations/0004_rename_user_category_users_alter_report_title.py new file mode 100644 index 0000000..ffc5382 --- /dev/null +++ b/georeport/migrations/0004_rename_user_category_users_alter_report_title.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.5 on 2025-02-10 19:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('georeport', '0003_category_close_with_link'), + ] + + operations = [ + migrations.RenameField( + model_name='category', + old_name='user', + new_name='users', + ), + migrations.AlterField( + model_name='report', + name='title', + field=models.CharField(max_length=100), + ), + ] diff --git a/georeport/models.py b/georeport/models.py index bf6d52c..c52b70e 100644 --- a/georeport/models.py +++ b/georeport/models.py @@ -29,9 +29,12 @@ class Category(models.Model): blank=True, ) - user = models.ManyToManyField(User, related_name="owner", blank=True) + users = models.ManyToManyField(User, related_name="owner", blank=True) groups = models.ManyToManyField(Group, related_name="group_owner", blank=True) + # TODO: Make it so, that descendens are also affected by the field + close_with_link = models.BooleanField(default=False) # type:ignore + class Meta: verbose_name_plural = "Categories" @@ -63,7 +66,7 @@ class Report(models.Model): category = models.ForeignKey( Category, on_delete=models.RESTRICT, related_name="reports" ) - title = models.CharField(max_length=100, unique=True) + title = models.CharField(max_length=100) description = models.TextField(blank=True, null=True) email = models.EmailField() # TODO: Images diff --git a/georeport/tests.py b/georeport/tests.py index 0380e4c..0fe21d7 100644 --- a/georeport/tests.py +++ b/georeport/tests.py @@ -174,7 +174,7 @@ class GetCategoryViewTests(TestCase): user = User.objects.create_user(username="test", password="1234") - self.root1.user.add(user) + self.root1.users.add(user) self.root1.save() self.client.login(username="test", password="1234") response = self.client.get(url) diff --git a/georeport/urls.py b/georeport/urls.py index 05bcf35..0f4de3b 100644 --- a/georeport/urls.py +++ b/georeport/urls.py @@ -18,5 +18,9 @@ urlpatterns = [ path("", views.report_detail_view, name="report"), path("requests/", views.report_detail_view, name="request-id"), path("create", views.create_report_view, name="create"), - # path("/", views.finish_link, name="finish"), + path( + "/", + views.close_with_link_view, + name="finish", + ), ] diff --git a/georeport/views.py b/georeport/views.py index 8ff38e3..ee2047a 100644 --- a/georeport/views.py +++ b/georeport/views.py @@ -23,6 +23,8 @@ from django.core.mail import send_mail from Crypto.Cipher import ChaCha20 from base64 import urlsafe_b64decode +from .admin import send_update + # TODO: test @require_safe @@ -84,7 +86,7 @@ def category_detail_view(request, id) -> HttpResponse: # Check if the user is allowed to view the category user = request.user - allowed_user = cat.user.all() + allowed_user = cat.users.all() allowed_groups = cat.groups.all() allowed = False if user.is_superuser: @@ -156,24 +158,31 @@ def report_detail_view(request, id): # TODO: Finish Link # TODO: Tests -def set_report_to_finish_view(request, b64nonce, b64ciphertext): - nonce = urlsafe_base64_decode(b64nonce) - ciphertext = urlsafe_base64_decode(b64ciphertext) +def close_with_link_view(request, b64nonce, b64ct): + nonce = urlsafe_b64decode(b64nonce) + ct = urlsafe_b64decode(b64ct) cipher = ChaCha20.new(key=settings.KEY, nonce=nonce) - id = int(cipher.decrypt(ciphertext)) + pk = cipher.decrypt(ct) + id = int(pk) report = get_object_or_404(Report, pk=id) if report.state == 1: report.state = 2 report.save() - - return redirect("georeport:detail", id) + send_update(report) + return redirect("georeport:report", id) # TODO:Tests -def send_creation_confirmation(report): - if not settings.email: +def send_creation_confirmation(report_dict): + if not settings.SEND_MAIL: return + report = ( + Report.objects.filter(title=report_dict["title"]) # type:ignore + .filter(latitude=report_dict["latitude"]) + .filter(longitude=report_dict["longitude"]) + .first() + ) recipient_list = [report.email] subject = "Report created" message = f'The report with title "{report.title}" was created with id {report.id}' @@ -182,14 +191,21 @@ def send_creation_confirmation(report): message=message, recipient_list=recipient_list, from_email=DEFAULT_FROM_EMAIL, + fail_silently=True, ) # TODO: Tests # TODO: Recruse groupmembers mail addresses -def send_creation_mail(report): - if not settings.send_mail: +def send_creation_mail(report_dict): + if not settings.SEND_MAIL: return + report = ( + Report.objects.filter(title=report_dict["title"]) # type:ignore + .filter(latitude=report_dict["latitude"]) + .filter(longitude=report_dict["longitude"]) + .first() + ) recipient_list = [] subject = f"Report {report.id} was created." message = ( @@ -207,4 +223,5 @@ def send_creation_mail(report): message=message, recipient_list=recipient_list, from_email=DEFAULT_FROM_EMAIL, + fail_silently=True, ) diff --git a/pinpoint_report/settings.py b/pinpoint_report/settings.py index add74f5..e5ea4c6 100644 --- a/pinpoint_report/settings.py +++ b/pinpoint_report/settings.py @@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/5.1/ref/settings/ import sys from pathlib import Path +from Crypto.Random import get_random_bytes # Build paths inside the project like this: BASE_DIR / 'subdir'. @@ -150,3 +151,7 @@ SEND_MAIL = True EMAIL_HOST = "localhost" EMAIL_PORT = "8025" DEFAULT_FROM_EMAIL = "example@pinpoint-report.de" + +# Setup for ciphers +# WARNING: It is advised to use a fixes 32 byte string in production +KEY = get_random_bytes(32) -- 2.39.5