Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions backend/core/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,27 @@
'base_url': ''
}
]

SUPPORTED_NOTIFICATION_PROVIDERS = [
{
'id': 'email',
'label': 'Email',
'description': 'Email notifications via SMTP',
'config_fields': ['email'],
'required_fields': ['email']
},
{
'id': 'slack',
'label': 'Slack',
'description': 'Slack webhook notifications',
'config_fields': ['webhookUrl'],
'required_fields': ['webhookUrl']
},
{
'id': 'discord',
'label': 'Discord',
'description': 'Discord webhook notifications',
'config_fields': ['webhookUrl'],
'required_fields': ['webhookUrl']
}
]
34 changes: 33 additions & 1 deletion backend/core/fields.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from django.db import models
from cryptography.fernet import Fernet
from cryptography.fernet import Fernet, InvalidToken
import json
from config import settings

fernet = Fernet(settings.SECRET_ENCRYPTION_KEY)


class EncryptedCharField(models.CharField):
def get_prep_value(self, value):
if value:
Expand All @@ -14,3 +16,33 @@ def from_db_value(self, value, expression, connection):
if value:
return fernet.decrypt(value.encode()).decode()
return value


class EncryptedJSONField(models.TextField):
def get_prep_value(self, value):
if value is None:
return None
if isinstance(value, str):
# Ensure it's valid JSON before encrypting
value = json.loads(value)
return fernet.encrypt(json.dumps(value).encode()).decode()

def from_db_value(self, value, expression, connection):
if value is None:
return None
try:
decrypted = fernet.decrypt(value.encode()).decode()
return json.loads(decrypted)
except Exception:
# Legacy plain JSON (stored before encryption was added)
try:
return json.loads(value)
except (ValueError, TypeError):
return {}

def to_python(self, value):
if value is None or isinstance(value, dict):
return value
if isinstance(value, str):
return json.loads(value)
return value
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Generated by Django 5.2.3 on 2026-03-16 16:49

import core.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0022_aiprovidermodels'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AlterModelOptions(
name='notificationprofile',
options={'ordering': ['name', 'created_at']},
),
migrations.RemoveField(
model_name='notificationprofile',
name='_config',
),
migrations.AddField(
model_name='notificationprofile',
name='config',
field=core.fields.EncryptedJSONField(blank=True, default=dict, help_text='Encrypted configuration data for the notification provider'),
),
migrations.AddField(
model_name='notificationprofile',
name='is_enabled',
field=models.BooleanField(default=True, help_text='Whether this notification profile is currently active'),
),
migrations.AddField(
model_name='notificationprofile',
name='metadata',
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name='notificationprofile',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name='notificationprofile',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='notificationprofile',
name='name',
field=models.CharField(help_text='Human-readable name for the notification profile', max_length=255),
),
migrations.AlterField(
model_name='notificationprofile',
name='owner',
field=models.ForeignKey(help_text='User who owns this notification profile', on_delete=django.db.models.deletion.CASCADE, related_name='notification_profiles', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='notificationprofile',
name='type',
field=models.CharField(help_text='Type of notification provider (email, slack, discord, whatsapp)', max_length=20),
),
migrations.AddIndex(
model_name='notificationprofile',
index=models.Index(fields=['owner', 'type'], name='core_notifi_owner_i_85656b_idx'),
),
migrations.AddIndex(
model_name='notificationprofile',
index=models.Index(fields=['owner', 'is_enabled'], name='core_notifi_owner_i_1430e6_idx'),
),
]
12 changes: 12 additions & 0 deletions backend/core/migrations/0026_merge_notification_and_chatroom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('core', '0023_alter_notificationprofile_options_and_more'),
('core', '0025_chatroom_ai_provider_chatroom_model'),
]

operations = [
]
14 changes: 14 additions & 0 deletions backend/core/migrations/0037_merge_20260325_1653.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generated by Django 5.2.3 on 2026-03-25 16:53

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('core', '0026_merge_notification_and_chatroom'),
('core', '0036_alter_message_options_and_more'),
]

operations = [
]
48 changes: 21 additions & 27 deletions backend/core/models/notification_profiles.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,27 @@
import uuid
from django.db import models
from django.contrib.auth.models import User
from django.db import models

class NotificationProfile(models.Model):
TYPE_CHOICES = [
('email', 'Email'),
('slack', 'Slack'),
('discord', 'Discord'),
('whatsapp', 'WhatsApp'),
]
from core.fields import EncryptedJSONField
from .base_model import BaseModel

id = models.AutoField(primary_key=True)
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
name = models.CharField(max_length=255)
type = models.CharField(max_length=20, choices=TYPE_CHOICES)
_config = models.TextField(db_column="config")
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notification_profiles')
created_at = models.DateTimeField(auto_now_add=True)

def __str__(self):
return f"{self.name} - {self.type.capitalize()} Notification Profile ({self.uuid})"

@property
def config(self):
from core.services.encryption import decrypt
return decrypt(self._config)
class NotificationProfile(BaseModel):
name = models.CharField(max_length=255)
type = models.CharField(max_length=20)
config = EncryptedJSONField(default=dict, blank=True)
is_enabled = models.BooleanField(default=True)
owner = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='notification_profiles',
)

@config.setter
def config(self, value):
from core.services.encryption import encrypt
self._config = encrypt(value or {})
class Meta:
ordering = ['name', 'created_at']
indexes = [
models.Index(fields=['owner', 'type']),
models.Index(fields=['owner', 'is_enabled']),
]

def __str__(self):
return f"{self.name} ({self.type})"
1 change: 1 addition & 0 deletions backend/core/serializers/chatroom.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class ChatRoomWithMessagesSerializer(serializers.ModelSerializer):
ai_model = serializers.CharField(source='model', read_only=True)
messages = serializers.SerializerMethodField()
chatroom = ChatRoomViewSerializer(read_only=True)
chatroom = ChatRoomViewSerializer(read_only=True)

class Meta:
model = ChatRoom
Expand Down
9 changes: 6 additions & 3 deletions backend/core/serializers/configure_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ def get_notification_profiles(self, obj):

data = []
for profile in profiles:
profile_data = NotificationProfileSerializer(profile).data
profile_data["is_enabled"] = profile.id in enabled_profile_ids
data.append(profile_data)
try:
profile_data = NotificationProfileSerializer(profile).data
profile_data["is_enabled"] = profile.id in enabled_profile_ids
data.append(profile_data)
except Exception:
pass

return data

Expand Down
105 changes: 84 additions & 21 deletions backend/core/serializers/notification_profiles.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,97 @@
import json
from rest_framework import serializers
from core.models import NotificationProfile
from core.services.encryption import encrypt
from core.consts import SUPPORTED_NOTIFICATION_PROVIDERS

PROVIDER_MAP = {p['id']: p for p in SUPPORTED_NOTIFICATION_PROVIDERS}


class BaseProviderValidator:
def validate(self, config):
pass


class EmailProviderValidator(BaseProviderValidator):
def validate(self, config):
email = config.get('email', '').strip()
if '@' not in email or '.' not in email.split('@')[-1]:
raise serializers.ValidationError({'config': {'email': 'Enter a valid email address.'}})


class WebhookProviderValidator(BaseProviderValidator):
webhook_domain = None

def validate(self, config):
url = config.get('webhookUrl', '').strip()
if not url.startswith(('http://', 'https://')):
raise serializers.ValidationError(
{'config': {'webhookUrl': 'Must be a valid http/https URL.'}}
)
if self.webhook_domain and self.webhook_domain not in url:
raise serializers.ValidationError(
{'config': {'webhookUrl': f"Webhooks must contain '{self.webhook_domain}'."}}
)


class SlackProviderValidator(WebhookProviderValidator):
webhook_domain = 'hooks.slack.com'


class DiscordProviderValidator(WebhookProviderValidator):
webhook_domain = 'discord.com'


_VALIDATOR_REGISTRY = {
'email': EmailProviderValidator(),
'slack': SlackProviderValidator(),
'discord': DiscordProviderValidator(),
}


def validate_config_for_type(provider_type, config):
if not isinstance(config, dict):
raise serializers.ValidationError({'config': 'Must be a JSON object.'})

provider = PROVIDER_MAP.get(provider_type)
if not provider:
raise serializers.ValidationError({'type': f'Unsupported provider: {provider_type}'})

missing = [f for f in provider.get('required_fields', []) if not config.get(f)]
if missing:
raise serializers.ValidationError(
{'config': f"Missing required fields: {', '.join(missing)}"}
)

validator = _VALIDATOR_REGISTRY.get(provider_type, BaseProviderValidator())
validator.validate(config)


class NotificationProfileSerializer(serializers.ModelSerializer):
config = serializers.JSONField()
config = serializers.JSONField(write_only=True)
config_meta = serializers.SerializerMethodField()

class Meta:
model = NotificationProfile
fields = ['id', 'uuid', 'type', 'config', 'created_at', 'name', 'owner']
read_only_fields = ['id', 'created_at', 'owner']
fields = ['uuid', 'name', 'type', 'config', 'config_meta', 'is_enabled', 'owner', 'created_at', 'updated_at']
read_only_fields = ['uuid', 'owner', 'created_at', 'updated_at']

def create(self, validated_data):
config = validated_data.pop('config', {})
def get_config_meta(self, obj):
config = obj.config or {}
safe = {}
if 'email' in config:
safe['hasEmail'] = bool(config.get('email'))
if 'webhookUrl' in config:
safe['hasWebhookUrl'] = bool(config.get('webhookUrl'))
return safe

instance = NotificationProfile(**validated_data)
def validate(self, attrs):
provider_type = attrs.get('type', getattr(self.instance, 'type', None))
config = attrs.get('config', getattr(self.instance, 'config', None))

encrypted_config = encrypt(config)
instance._config = encrypt(encrypted_config)
instance.save()
return instance
if provider_type and config is not None:
validate_config_for_type(provider_type, config)

def to_representation(self, instance):
representation = super().to_representation(instance)
return attrs

config = getattr(instance, 'config', {})
filtered_config = {}
if isinstance(config, dict) and 'email' in config:
filtered_config['email'] = config['email']

representation['config'] = filtered_config
return representation
def create(self, validated_data):
validated_data['owner'] = self.context['request'].user
return super().create(validated_data)
Loading
Loading