diff --git a/core/core/settings.py b/core/core/settings.py index 945960b..80e2e3a 100644 --- a/core/core/settings.py +++ b/core/core/settings.py @@ -61,6 +61,7 @@ INSTALLED_APPS = [ 'drf_yasg', 'channels', 'authentication', + 'notifications', 'files', 'tickets', 'inventory', diff --git a/core/mail/migrations/0005_usernotificationchannel.py b/core/mail/migrations/0005_usernotificationchannel.py deleted file mode 100644 index 629f20c..0000000 --- a/core/mail/migrations/0005_usernotificationchannel.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 4.2.7 on 2024-04-26 13:08 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('mail', '0004_alter_emailattachment_file'), - ] - - operations = [ - migrations.CreateModel( - name='UserNotificationChannel', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('channel_type', models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)), - ('channel_target', models.CharField(max_length=255)), - ('event_filter', models.CharField(max_length=255)), - ('active', models.BooleanField(default=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/core/mail/models.py b/core/mail/models.py index 037dcac..ca71c88 100644 --- a/core/mail/models.py +++ b/core/mail/models.py @@ -41,13 +41,4 @@ class EmailAttachment(AbstractFile): name = models.CharField(max_length=255) -class UserNotificationChannel(models.Model): - user = models.ForeignKey(ExtendedUser, models.CASCADE) - channel_type = models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255) - channel_target = models.CharField(max_length=255) - event_filter = models.CharField(max_length=255) - active = models.BooleanField(default=True) - created = models.DateTimeField(auto_now_add=True) - def validate_constraints(self, exclude=None): # TODO: email -> emailaddress, telegram -> chatid - return True diff --git a/core/mail/protocol.py b/core/mail/protocol.py index 2d80e3b..2a175c5 100644 --- a/core/mail/protocol.py +++ b/core/mail/protocol.py @@ -5,8 +5,8 @@ from channels.layers import get_channel_layer from channels.db import database_sync_to_async from django.core.files.base import ContentFile -from core.settings import PRIMARY_HOST from mail.models import Email, EventAddress, EmailAttachment +from notifications.templates import render_auto_reply from notify_sessions.models import SystemEvent from tickets.models import IssueThread @@ -83,14 +83,13 @@ def make_reply(reply_email, references=None, event=None): return reply -def make_notification(message, to, event=None): # TODO where should replies to this go +def make_notification(message, to, title): # TODO where should replies to this go from email.message import EmailMessage from core.settings import MAIL_DOMAIN - event = event or "mail" notification = EmailMessage() notification["From"] = "notifications@%s" % MAIL_DOMAIN notification["To"] = to - notification["Subject"] = f"System3 Notification" + notification["Subject"] = f"[C3LF Notification]%s" % title # notification["Reply-To"] = f"{event}@{MAIL_DOMAIN}" # notification["In-Reply-To"] = email.reference # notification["Message-ID"] = email.id + "@" + MAIL_DOMAIN @@ -198,11 +197,11 @@ def receive_email(envelope, log=None): header_in_reply_to = parsed.get('In-Reply-To') header_message_id = parsed.get('Message-ID') - #if header_from != envelope.mail_from: + # if header_from != envelope.mail_from: # log.warning("Header from does not match envelope from") # log.info(f"Header from: {header_from}, envelope from: {envelope.mail_from}") -# - #if header_to != envelope.rcpt_tos[0]: + # + # if header_to != envelope.rcpt_tos[0]: # log.warning("Header to does not match envelope to") # log.info(f"Header to: {header_to}, envelope to: {envelope.rcpt_tos[0]}") @@ -231,16 +230,7 @@ def receive_email(envelope, log=None): references = collect_references(active_issue_thread) if not sender.startswith('noreply'): subject = f"Re: {subject} [#{active_issue_thread.short_uuid()}]" - body = '''Your request (#{}) has been received and will be reviewed by our lost&found angels. - -We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \ -workload is high. We will not forget about your request and get back in touch once we have updated information on your \ -request. Requests for devices, wallets, credit cards or similar items will be handled with priority. - -If you happen to find your lost item or just want to add additional information, please reply to this email. Please \ -do not create a new request. - -Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid()) + body = render_auto_reply(active_issue_thread) reply_email = Email.objects.create( sender=recipient, recipient=sender, body=body, subject=subject, in_reply_to=header_message_id, event=target_event, issue_thread=active_issue_thread) @@ -251,20 +241,7 @@ Your c3lf (Cloakroom + Lost&Found) Team'''.format(active_issue_thread.short_uuid active_issue_thread.state = 'pending_open' active_issue_thread.save() - notification = None - if len(active_issue_thread.name) > 50: - notify_subject = active_issue_thread.name[:47] + "..." - else: - notify_subject = active_issue_thread.name - eventslug = target_event.slug if target_event else "37C3" # TODO 37C3 should not be hardcoded - if new: - notification = f"""New issue \"{active_issue_thread.name}\" [{active_issue_thread.short_uuid()}] created -https://{PRIMARY_HOST}/{eventslug}/ticket/{active_issue_thread.id}/""" - else: - notification = f"""Reply to issue \"{active_issue_thread.name}\" [{active_issue_thread.short_uuid()}] -https://{PRIMARY_HOST}/{eventslug}/ticket/{active_issue_thread.id}/""" - - return email, new, reply, notification + return email, new, reply, active_issue_thread class LMTPHandler: @@ -286,7 +263,7 @@ class LMTPHandler: content = None try: content = envelope.content - email, new, reply, notification = await database_sync_to_async(receive_email)(envelope, log) + email, new, reply, thread = await database_sync_to_async(receive_email)(envelope, log) log.info(f"Created email {email.id}") systemevent = await database_sync_to_async(SystemEvent.objects.create)(type='email received', reference=email.id) @@ -296,11 +273,10 @@ class LMTPHandler: 'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id, "message": "email received"}) log.info(f"Sent message to frontend") - if notification: - log.info(f"Sending notification {notification}") + if thread: await channel_layer.group_send( 'general', {"type": "generic.event", "name": "user_notification", "event_id": systemevent.id, - "message": notification}) + "ticket": thread, "new": new}) if new and reply: log.info('Sending message to %s' % reply['To']) await send_smtp(reply) diff --git a/core/mail/tests/v2/test_user_notifications.py b/core/mail/tests/v2/test_user_notifications.py index e276640..c4db23f 100644 --- a/core/mail/tests/v2/test_user_notifications.py +++ b/core/mail/tests/v2/test_user_notifications.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import Permission from django.test import TestCase from authentication.models import ExtendedUser -from mail.models import UserNotificationChannel +from notifications.models import UserNotificationChannel class UserNotificationTestCase(TestCase): diff --git a/core/notifications/__init__.py b/core/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/notifications/defauls.py b/core/notifications/defauls.py new file mode 100644 index 0000000..7af6eb3 --- /dev/null +++ b/core/notifications/defauls.py @@ -0,0 +1,16 @@ +auto_reply_body = '''Your request (#{{ ticket_uuid }}) has been received and will be reviewed by our lost&found angels. + +We are reviewing incoming requests during the event and teardown. Immediately after the event, expect a delay as the \ +workload is high. We will not forget about your request and get back in touch once we have updated information on your \ +request. Requests for devices, wallets, credit cards or similar items will be handled with priority. + +If you happen to find your lost item or just want to add additional information, please reply to this email. Please \ +do not create a new request. + +Your c3lf (Cloakroom + Lost&Found) Team''' + +new_issue_notification = '''New issue "{{ ticket_name | limit_length }}" [{{ ticket_uuid }}] created +{{ ticket_url }}''' + +reply_issue_notification = '''Reply to issue "{{ ticket_name }}" [{{ ticket_uuid }}] +{{ ticket_url }}''' diff --git a/core/mail/notifications.py b/core/notifications/dispatch.py similarity index 84% rename from core/mail/notifications.py rename to core/notifications/dispatch.py index 3ad465a..c224f1d 100644 --- a/core/mail/notifications.py +++ b/core/notifications/dispatch.py @@ -4,8 +4,9 @@ from channels.db import database_sync_to_async from urllib.parse import quote as urlencode from core.settings import TELEGRAM_BOT_TOKEN, TELEGRAM_GROUP_CHAT_ID -from mail.models import UserNotificationChannel from mail.protocol import send_smtp, make_notification +from notifications.models import UserNotificationChannel +from notifications.templates import render_notification_new_ticket, render_notification_reply_ticket async def http_get(url): @@ -51,12 +52,13 @@ class NotificationDispatcher: if (message and 'type' in message and message['type'] == 'generic.event' and 'name' in message and message['name'] == 'user_notification'): - if 'message' in message and 'event_id' in message: - await self.dispatch(message['message'], message['event_id']) + if 'ticket' in message and 'event_id' in message and 'new' in message: + await self.dispatch(message['ticket'], message['event_id'], message['new']) else: print("Error: Invalid message format") - async def dispatch(self, message, event_id): + async def dispatch(self, ticket, event_id, new): + message = render_notification_new_ticket(ticket) if new else render_notification_reply_ticket(ticket) print("Dispatching message:", message, "with event_id:", event_id) targets = await self.get_notification_targets() await telegram_notify(message, TELEGRAM_GROUP_CHAT_ID) diff --git a/core/notifications/migrations/0001_initial.py b/core/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..dcfc4d2 --- /dev/null +++ b/core/notifications/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.7 on 2024-05-03 21:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +from notifications.defauls import auto_reply_body, new_issue_notification, reply_issue_notification +from notifications.models import MessageTemplate + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + def create_required_templates(apps, schema_editor): + MessageTemplate.objects.create(name='auto_reply', message=auto_reply_body) + MessageTemplate.objects.create(name='new_issue_notification', message=new_issue_notification) + MessageTemplate.objects.create(name='reply_issue_notification', message=reply_issue_notification) + + operations = [ + migrations.CreateModel( + name='MessageTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255)), + ('message', models.TextField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('marked_confidential', models.BooleanField(default=False)), + ], + ), + migrations.CreateModel( + name='UserNotificationChannel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('channel_type', + models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255)), + ('channel_target', models.CharField(max_length=255)), + ('event_filter', models.CharField(max_length=255)), + ('active', models.BooleanField(default=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.RunPython(create_required_templates), + ] diff --git a/core/notifications/migrations/__init__.py b/core/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/notifications/models.py b/core/notifications/models.py new file mode 100644 index 0000000..abb7bb7 --- /dev/null +++ b/core/notifications/models.py @@ -0,0 +1,22 @@ +from django.db import models + +from authentication.models import ExtendedUser + + +class MessageTemplate(models.Model): + name = models.CharField(max_length=255) + message = models.TextField() + created = models.DateTimeField(auto_now_add=True) + marked_confidential = models.BooleanField(default=False) + + +class UserNotificationChannel(models.Model): + user = models.ForeignKey(ExtendedUser, models.CASCADE) + channel_type = models.CharField(choices=[('telegram', 'telegram'), ('email', 'email')], max_length=255) + channel_target = models.CharField(max_length=255) + event_filter = models.CharField(max_length=255) + active = models.BooleanField(default=True) + created = models.DateTimeField(auto_now_add=True) + + def validate_constraints(self, exclude=None): # TODO: email -> emailaddress, telegram -> chatid + return True diff --git a/core/notifications/templates.py b/core/notifications/templates.py new file mode 100644 index 0000000..6703998 --- /dev/null +++ b/core/notifications/templates.py @@ -0,0 +1,54 @@ +import jinja2 +from core.settings import PRIMARY_HOST + +from notifications.models import MessageTemplate + +# auto_reply_title = f"Re: {{ ticket_name }} [#{{ ticket_uuid }}]" + + +TEMLATE_VARS = ['ticket_name', 'ticket_uuid', 'ticket_id', 'ticket_url', + 'event_slug', 'event_name', + 'username', 'user_nick', + 'web_host'] # TODO customer_name, tracking_code + + +def limit_length(s, length=50): + if len(s) > length: + return s[:(length - 3)] + "..." + return s + + +def ticket_url(ticket): + eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded + return f"https://{PRIMARY_HOST}/{eventslug}/ticket/{ticket.id}/" + + +def render_template(template, **kwargs): + try: + + environment = jinja2.Environment() + environment.filters['limit_length'] = limit_length + + tmpl = MessageTemplate.objects.get(name=template) + template = environment.from_string(tmpl.message) + return template.render(**kwargs, web_host=PRIMARY_HOST) + except MessageTemplate.DoesNotExist: + return None + + +def render_auto_reply(ticket): + eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded + return render_template('auto_reply', ticket_name=ticket.name, ticket_uuid=ticket.short_uuid(), + ticket_id=ticket.id, event_slug=eventslug, ticket_url=ticket_url(ticket)) + + +def render_notification_new_ticket(ticket): + eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded + return render_template('new_issue_notification', ticket_name=ticket.name, ticket_uuid=ticket.short_uuid(), + ticket_id=ticket.id, event_slug=eventslug, ticket_url=ticket_url(ticket)) + + +def render_notification_reply_ticket(ticket): + eventslug = ticket.event.slug if ticket.event else "37C3" # TODO 37C3 should not be hardcoded + return render_template('reply_issue_notification', ticket_name=ticket.name, ticket_uuid=ticket.short_uuid(), + ticket_id=ticket.id, event_slug=eventslug, ticket_url=ticket_url(ticket)) diff --git a/core/notifications/tests/__init__.py b/core/notifications/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/server.py b/core/server.py old mode 100644 new mode 100755 index bcccf81..a09e315 --- a/core/server.py +++ b/core/server.py @@ -12,7 +12,7 @@ django.setup() from helper import init_loop from mail.protocol import LMTPHandler from mail.socket import UnixSocketLMTPController -from mail.notifications import NotificationDispatcher +from notifications.dispatch import NotificationDispatcher class UvicornServer(uvicorn.Server): diff --git a/core/tickets/migrations/0009_issuethread_event.py b/core/tickets/migrations/0009_issuethread_event.py new file mode 100644 index 0000000..1ab69e4 --- /dev/null +++ b/core/tickets/migrations/0009_issuethread_event.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.7 on 2024-05-03 21:21 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('inventory', '0004_alter_event_created_at_alter_item_created_at'), + ('tickets', '0008_alter_issuethread_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='issuethread', + name='event', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='issue_threads', to='inventory.event'), + ), + ] diff --git a/core/tickets/models.py b/core/tickets/models.py index 6dd42c3..269403a 100644 --- a/core/tickets/models.py +++ b/core/tickets/models.py @@ -33,6 +33,7 @@ class IssueThread(SoftDeleteModel): id = models.AutoField(primary_key=True) uuid = models.CharField(max_length=255, unique=True, null=False, blank=False) name = models.CharField(max_length=255) + event = models.ForeignKey(Event, null=True, on_delete=models.SET_NULL, related_name='issue_threads') manually_created = models.BooleanField(default=False) def short_uuid(self):