diff --git a/core/authentication/api_v2.py b/core/authentication/api_v2.py
index 514a697..2547b6d 100644
--- a/core/authentication/api_v2.py
+++ b/core/authentication/api_v2.py
@@ -12,25 +12,7 @@ from knox.models import AuthToken
from knox.views import LoginView as KnoxLoginView
from authentication.models import ExtendedUser
-
-
-class UserSerializer(serializers.ModelSerializer):
- permissions = serializers.SerializerMethodField()
- groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name')
-
- class Meta:
- model = ExtendedUser
- fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
- read_only_fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
-
- def get_permissions(self, obj):
- return list(set(obj.get_permissions()))
-
-
-@receiver(post_save, sender=ExtendedUser)
-def create_auth_token(sender, instance=None, created=False, **kwargs):
- if created:
- AuthToken.objects.create(user=instance)
+from authentication.serializers import UserSerializer, GroupSerializer
class UserViewSet(viewsets.ModelViewSet):
@@ -38,26 +20,17 @@ class UserViewSet(viewsets.ModelViewSet):
serializer_class = UserSerializer
-class GroupSerializer(serializers.ModelSerializer):
- permissions = serializers.SerializerMethodField()
- members = serializers.SerializerMethodField()
-
- class Meta:
- model = Group
- fields = ('id', 'name', 'permissions', 'members')
-
- def get_permissions(self, obj):
- return ["*:" + p.codename for p in obj.permissions.all()]
-
- def get_members(self, obj):
- return [u.username for u in obj.user_set.all()]
-
-
class GroupViewSet(viewsets.ModelViewSet):
queryset = Group.objects.all()
serializer_class = GroupSerializer
+@receiver(post_save, sender=ExtendedUser)
+def create_auth_token(sender, instance=None, created=False, **kwargs):
+ if created:
+ AuthToken.objects.create(user=instance)
+
+
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def selfUser(request):
diff --git a/core/authentication/serializers.py b/core/authentication/serializers.py
new file mode 100644
index 0000000..0581865
--- /dev/null
+++ b/core/authentication/serializers.py
@@ -0,0 +1,32 @@
+from rest_framework import serializers
+from django.contrib.auth.models import Group
+
+from authentication.models import ExtendedUser
+
+
+class UserSerializer(serializers.ModelSerializer):
+ permissions = serializers.SerializerMethodField()
+ groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name')
+
+ class Meta:
+ model = ExtendedUser
+ fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
+ read_only_fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
+
+ def get_permissions(self, obj):
+ return list(set(obj.get_permissions()))
+
+
+class GroupSerializer(serializers.ModelSerializer):
+ permissions = serializers.SerializerMethodField()
+ members = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Group
+ fields = ('id', 'name', 'permissions', 'members')
+
+ def get_permissions(self, obj):
+ return ["*:" + p.codename for p in obj.permissions.all()]
+
+ def get_members(self, obj):
+ return [u.username for u in obj.user_set.all()]
diff --git a/core/core/settings.py b/core/core/settings.py
index 2d4a818..7612fd4 100644
--- a/core/core/settings.py
+++ b/core/core/settings.py
@@ -32,7 +32,9 @@ SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-tm*$w_14iqbiy-!7(8#
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = truthy_str(os.getenv('DEBUG_MODE_ACTIVE', 'False'))
-ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')]
+PRIMARY_HOST = os.getenv('HTTP_HOST', 'localhost')
+
+ALLOWED_HOSTS = [PRIMARY_HOST]
MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost')
@@ -45,6 +47,10 @@ SYSTEM3_VERSION = "0.0.0-dev.0"
ACTIVE_SPAM_TRAINING = truthy_str(os.getenv('ACTIVE_SPAM_TRAINING', 'False'))
+TELEGRAM_BOT_TOKEN = os.getenv('TELEGRAM_BOT_TOKEN', '1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi')
+
+TELEGRAM_GROUP_CHAT_ID = os.getenv('TELEGRAM_GROUP_CHAT_ID', '-1234567890')
+
# Application definition
INSTALLED_APPS = [
@@ -61,6 +67,7 @@ INSTALLED_APPS = [
'drf_yasg',
'channels',
'authentication',
+ 'notifications',
'files',
'tickets',
'inventory',
diff --git a/core/core/urls.py b/core/core/urls.py
index df6e0d0..228ef89 100644
--- a/core/core/urls.py
+++ b/core/core/urls.py
@@ -31,6 +31,7 @@ urlpatterns = [
path('api/2/', include('mail.api_v2')),
path('api/2/', include('notify_sessions.api_v2')),
path('api/2/', include('authentication.api_v2')),
+ path('api/2/', include('notifications.api_v2')),
path('api/', get_info),
path('', include('django_prometheus.urls')),
]
diff --git a/core/mail/protocol.py b/core/mail/protocol.py
index 926e8c2..5cf54bb 100644
--- a/core/mail/protocol.py
+++ b/core/mail/protocol.py
@@ -7,6 +7,7 @@ from channels.db import database_sync_to_async
from django.core.files.base import ContentFile
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
@@ -87,6 +88,22 @@ def make_reply(reply_email, references=None, event=None):
return reply
+def make_notification(message, to, title): # TODO where should replies to this go
+ from email.message import EmailMessage
+ from core.settings import MAIL_DOMAIN
+ notification = EmailMessage()
+ notification["From"] = "notifications@%s" % MAIL_DOMAIN
+ notification["To"] = to
+ 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
+
+ notification.set_content(message)
+
+ return notification
+
+
async def send_smtp(message):
await aiosmtplib.send(message, hostname="127.0.0.1", port=25, use_tls=False, start_tls=False)
@@ -218,16 +235,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)
@@ -270,11 +278,19 @@ 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 new and reply:
log.info('Sending message to %s' % reply['To'])
await send_smtp(reply)
log.info("Sent auto reply")
+ if thread:
+ await channel_layer.group_send(
+ 'general', {"type": "generic.event", "name": "user_notification", "event_id": systemevent.id,
+ "ticket_id": thread.id, "new": new})
+ else:
+ print("No thread found")
+
return '250 Message accepted for delivery'
except SpecialMailException as e:
import uuid
diff --git a/core/mail/tests/v2/test_user_notifications.py b/core/mail/tests/v2/test_user_notifications.py
new file mode 100644
index 0000000..c4db23f
--- /dev/null
+++ b/core/mail/tests/v2/test_user_notifications.py
@@ -0,0 +1,20 @@
+from django.contrib.auth.models import Permission
+from django.test import TestCase
+
+from authentication.models import ExtendedUser
+from notifications.models import UserNotificationChannel
+
+
+class UserNotificationTestCase(TestCase):
+
+ def setUp(self):
+ super().setUp()
+ self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
+ self.user.user_permissions.add(*Permission.objects.all())
+ self.user.save()
+ self.channel = UserNotificationChannel.objects.create(user=self.user, channel_type='telegram',
+ channel_target='123456789',
+ event_filter='*', active=True)
+
+ async def test_telegram_notify(self):
+ pass
diff --git a/core/notifications/__init__.py b/core/notifications/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/core/notifications/admin.py b/core/notifications/admin.py
new file mode 100644
index 0000000..69620a9
--- /dev/null
+++ b/core/notifications/admin.py
@@ -0,0 +1,15 @@
+from django.contrib import admin
+
+from notifications.models import MessageTemplate, UserNotificationChannel
+
+
+class MessageTemplateAdmin(admin.ModelAdmin):
+ pass
+
+
+class UserNotificationChannelAdmin(admin.ModelAdmin):
+ pass
+
+
+admin.site.register(MessageTemplate, MessageTemplateAdmin)
+admin.site.register(UserNotificationChannel, UserNotificationChannelAdmin)
diff --git a/core/notifications/api_v2.py b/core/notifications/api_v2.py
new file mode 100644
index 0000000..a9492f5
--- /dev/null
+++ b/core/notifications/api_v2.py
@@ -0,0 +1,51 @@
+from django.contrib.auth.decorators import permission_required
+from rest_framework import routers, viewsets
+from django.urls import re_path
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.response import Response
+
+from notifications.models import MessageTemplate, UserNotificationChannel
+from rest_framework import serializers
+
+from notifications.templates import TEMPLATE_VARS
+from authentication.serializers import UserSerializer
+
+
+class MessageTemplateSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = MessageTemplate
+ fields = '__all__'
+
+
+class UserNotificationChannelSerializer(serializers.ModelSerializer):
+ user = UserSerializer()
+
+ class Meta:
+ model = UserNotificationChannel
+ fields = '__all__'
+
+
+class MessageTemplateViewSet(viewsets.ModelViewSet):
+ serializer_class = MessageTemplateSerializer
+ queryset = MessageTemplate.objects.all()
+
+
+class UserNotificationChannelViewSet(viewsets.ModelViewSet):
+ serializer_class = UserNotificationChannelSerializer
+ queryset = UserNotificationChannel.objects.all()
+
+
+@api_view(['GET'])
+@permission_classes([IsAuthenticated])
+@permission_required('tickets.add_issuethread_manual', raise_exception=True) # TDOO: change this permission
+def get_template_vars(self):
+ return Response(TEMPLATE_VARS, status=200)
+
+
+router = routers.SimpleRouter()
+router.register(r'message_templates', MessageTemplateViewSet)
+router.register(r'user_notification_channels', UserNotificationChannelViewSet)
+urlpatterns = ([
+ re_path('message_template_variables', get_template_vars),
+ ] + router.urls)
diff --git a/core/notifications/defaults.py b/core/notifications/defaults.py
new file mode 100644
index 0000000..812d93e
--- /dev/null
+++ b/core/notifications/defaults.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 }}] (was {{ previous_state_pretty }})
+{{ ticket_url }}'''
diff --git a/core/notifications/dispatch.py b/core/notifications/dispatch.py
new file mode 100644
index 0000000..752c342
--- /dev/null
+++ b/core/notifications/dispatch.py
@@ -0,0 +1,85 @@
+import asyncio
+
+from aiohttp.client import ClientSession
+from channels.layers import get_channel_layer
+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.protocol import send_smtp, make_notification
+from notifications.models import UserNotificationChannel
+from notifications.templates import render_notification_new_ticket_async, render_notification_reply_ticket_async
+from tickets.models import IssueThread
+
+
+async def http_get(url):
+ async with ClientSession() as session:
+ async with session.get(url) as response:
+ return await response.text()
+
+
+async def telegram_notify(message, chat_id):
+ encoded_message = urlencode(message)
+ url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage?chat_id={chat_id}&text={encoded_message}"
+ return await http_get(url)
+
+
+async def email_notify(message, title, email):
+ mail = make_notification(message, email, title)
+ await send_smtp(mail)
+
+
+class NotificationDispatcher:
+ channel_layer = None
+ room_group_name = "general"
+
+ def __init__(self):
+ self.channel_layer = get_channel_layer('default')
+ if not self.channel_layer:
+ raise Exception("Could not get channel layer")
+
+ @database_sync_to_async
+ def get_notification_targets(self):
+ channels = UserNotificationChannel.objects.filter(active=True)
+ return list(channels)
+
+ @database_sync_to_async
+ def get_ticket(self, ticket_id):
+ return IssueThread.objects.filter(id=ticket_id).select_related('event').first()
+
+ async def run_forever(self):
+ # Infinite loop to continuously listen for messages
+ print("Listening for messages...")
+ channel_name = await self.channel_layer.new_channel()
+ await self.channel_layer.group_add(self.room_group_name, channel_name)
+ print("Channel name:", channel_name)
+ while True:
+ # Blocking receive to get the message from the channel layer
+ message = await self.channel_layer.receive(channel_name)
+
+ if (message and 'type' in message and message['type'] == 'generic.event' and 'name' in message and
+ message['name'] == 'user_notification'):
+ if 'ticket_id' in message and 'event_id' in message and 'new' in message:
+ ticket = await self.get_ticket(message['ticket_id'])
+ await self.dispatch(ticket, message['event_id'], message['new'])
+ else:
+ print("Error: Invalid message format")
+
+ async def dispatch(self, ticket, event_id, new):
+ message = await render_notification_new_ticket_async(
+ ticket) if new else await render_notification_reply_ticket_async(ticket)
+ title = f"[#{ticket.short_uuid()}] {ticket.name}"
+ print("Dispatching message:", message, "with event_id:", event_id)
+ targets = await self.get_notification_targets()
+ jobs = []
+ jobs.append(telegram_notify(message, TELEGRAM_GROUP_CHAT_ID))
+ for target in targets:
+ if target.channel_type == 'telegram':
+ print("Sending telegram notification to:", target.channel_target)
+ jobs.append(telegram_notify(message, target.channel_target))
+ elif target.channel_type == 'email':
+ print("Sending email notification to:", target.channel_target)
+ jobs.append(email_notify(message, title, target.channel_target))
+ else:
+ print("Unknown channel type:", target.channel_type)
+ await asyncio.gather(*jobs)
diff --git a/core/notifications/migrations/0001_initial.py b/core/notifications/migrations/0001_initial.py
new file mode 100644
index 0000000..4d276eb
--- /dev/null
+++ b/core/notifications/migrations/0001_initial.py
@@ -0,0 +1,51 @@
+# 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.defaults 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, marked_required=True)
+ MessageTemplate.objects.create(name='new_issue_notification', message=new_issue_notification,
+ marked_required=True)
+ MessageTemplate.objects.create(name='reply_issue_notification', message=reply_issue_notification,
+ marked_required=True)
+
+ 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)),
+ ('marked_required', 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..9cbfbe5
--- /dev/null
+++ b/core/notifications/models.py
@@ -0,0 +1,29 @@
+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)
+ marked_required = models.BooleanField(default=False) # may not be deleted
+
+ def __str__(self):
+ return self.name
+
+
+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
+
+ def __str__(self):
+ return self.user.username + '(' + self.channel_type + ')'
diff --git a/core/notifications/templates.py b/core/notifications/templates.py
new file mode 100644
index 0000000..af77193
--- /dev/null
+++ b/core/notifications/templates.py
@@ -0,0 +1,69 @@
+import jinja2
+from channels.db import database_sync_to_async
+from core.settings import PRIMARY_HOST
+
+from notifications.models import MessageTemplate
+
+TEMPLATE_VARS = ['ticket_name', 'ticket_uuid', 'ticket_id', 'ticket_url',
+ 'current_state', 'previous_state', 'current_state_pretty', 'previous_state_pretty',
+ '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 get_ticket_vars(ticket):
+ states = list(ticket.state_changes.order_by('-timestamp'))
+ return {
+ 'ticket_name': ticket.name,
+ 'ticket_uuid': ticket.short_uuid(),
+ 'ticket_id': ticket.id,
+ 'ticket_url': ticket_url(ticket),
+ 'current_state': states[0].state if states else 'none',
+ 'previous_state': states[1].state if len(states) > 1 else 'none',
+ 'current_state_pretty': states[0].get_state_display() if states else 'none',
+ 'previous_state_pretty': states[1].get_state_display() if len(states) > 1 else 'none',
+ 'event_slug': ticket.event.slug if ticket.event else "37C3", # TODO 37C3 should not be hardcoded
+ 'event_name': ticket.event.name if ticket.event else "37C3",
+ }
+
+
+def render_auto_reply(ticket):
+ return render_template('auto_reply', **get_ticket_vars(ticket))
+
+
+def render_notification_new_ticket(ticket):
+ return render_template('new_issue_notification', **get_ticket_vars(ticket))
+
+
+def render_notification_reply_ticket(ticket):
+ return render_template('reply_issue_notification', **get_ticket_vars(ticket))
+
+
+async def render_notification_new_ticket_async(ticket):
+ return await database_sync_to_async(render_notification_new_ticket)(ticket)
+
+
+async def render_notification_reply_ticket_async(ticket):
+ return await database_sync_to_async(render_notification_reply_ticket)(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
index d08b595..a09e315 100644
--- a/core/server.py
+++ b/core/server.py
@@ -12,6 +12,7 @@ django.setup()
from helper import init_loop
from mail.protocol import LMTPHandler
from mail.socket import UnixSocketLMTPController
+from notifications.dispatch import NotificationDispatcher
class UvicornServer(uvicorn.Server):
@@ -54,6 +55,11 @@ async def lmtp(loop):
log.info("LMTP done")
+async def notifications(loop):
+ dispatcher = NotificationDispatcher()
+ await dispatcher.run_forever()
+
+
def main():
import sdnotify
import setproctitle
@@ -67,6 +73,7 @@ def main():
loop.create_task(web(loop))
# loop.create_task(tcp(loop))
loop.create_task(lmtp(loop))
+ loop.create_task(notifications(loop))
n = sdnotify.SystemdNotifier()
n.notify("READY=1")
log.info("Server ready")
diff --git a/deploy/ansible/playbooks/templates/django.env.j2 b/deploy/ansible/playbooks/templates/django.env.j2
index c9b1c83..748ecf4 100644
--- a/deploy/ansible/playbooks/templates/django.env.j2
+++ b/deploy/ansible/playbooks/templates/django.env.j2
@@ -13,3 +13,5 @@ STATIC_ROOT=/var/www/c3lf-sys3/staticfiles
ACTIVE_SPAM_TRAINING=True
DEBUG_MODE_ACTIVE={{ debug_mode_active }}
DJANGO_SECRET_KEY={{ django_secret_key }}
+TELEGRAM_GROUP_CHAT_ID={{ telegram_group_chat_id }}
+TELEGRAM_BOT_TOKEN={{ telegram_bot_token }}
\ No newline at end of file
diff --git a/web/src/components/inputs/FormatedText.vue b/web/src/components/inputs/FormatedText.vue
new file mode 100644
index 0000000..114fbf3
--- /dev/null
+++ b/web/src/components/inputs/FormatedText.vue
@@ -0,0 +1,112 @@
+
+
+ + {{ variable }} + +
+