diff --git a/backend/core/consts.py b/backend/core/consts.py index e833897..55f464d 100644 --- a/backend/core/consts.py +++ b/backend/core/consts.py @@ -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'] + } +] diff --git a/backend/core/fields.py b/backend/core/fields.py index 2e517ac..770e856 100644 --- a/backend/core/fields.py +++ b/backend/core/fields.py @@ -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: @@ -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 diff --git a/backend/core/migrations/0023_alter_notificationprofile_options_and_more.py b/backend/core/migrations/0023_alter_notificationprofile_options_and_more.py new file mode 100644 index 0000000..9dcd89a --- /dev/null +++ b/backend/core/migrations/0023_alter_notificationprofile_options_and_more.py @@ -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'), + ), + ] diff --git a/backend/core/migrations/0026_merge_notification_and_chatroom.py b/backend/core/migrations/0026_merge_notification_and_chatroom.py new file mode 100644 index 0000000..2004713 --- /dev/null +++ b/backend/core/migrations/0026_merge_notification_and_chatroom.py @@ -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 = [ + ] diff --git a/backend/core/migrations/0037_merge_20260325_1653.py b/backend/core/migrations/0037_merge_20260325_1653.py new file mode 100644 index 0000000..f021f81 --- /dev/null +++ b/backend/core/migrations/0037_merge_20260325_1653.py @@ -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 = [ + ] diff --git a/backend/core/models/notification_profiles.py b/backend/core/models/notification_profiles.py index 3fcecfd..3357955 100644 --- a/backend/core/models/notification_profiles.py +++ b/backend/core/models/notification_profiles.py @@ -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})" diff --git a/backend/core/serializers/chatroom.py b/backend/core/serializers/chatroom.py index 163d541..0604aef 100644 --- a/backend/core/serializers/chatroom.py +++ b/backend/core/serializers/chatroom.py @@ -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 diff --git a/backend/core/serializers/configure_app.py b/backend/core/serializers/configure_app.py index bb897a2..a8471a0 100644 --- a/backend/core/serializers/configure_app.py +++ b/backend/core/serializers/configure_app.py @@ -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 diff --git a/backend/core/serializers/notification_profiles.py b/backend/core/serializers/notification_profiles.py index 26a2f00..4f3ab21 100644 --- a/backend/core/serializers/notification_profiles.py +++ b/backend/core/serializers/notification_profiles.py @@ -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 \ No newline at end of file + def create(self, validated_data): + validated_data['owner'] = self.context['request'].user + return super().create(validated_data) diff --git a/backend/core/tests/factories.py b/backend/core/tests/factories.py index 92143dc..83d6054 100644 --- a/backend/core/tests/factories.py +++ b/backend/core/tests/factories.py @@ -4,9 +4,11 @@ Application, AIProvider, Integration, - AppIntegration + AppIntegration, + NotificationProfile, ) + class UserFactory(factory.django.DjangoModelFactory): class Meta: model = User @@ -17,6 +19,7 @@ class Meta: last_name = factory.Faker('last_name') is_active = True + class ApplicationFactory(factory.django.DjangoModelFactory): class Meta: model = Application @@ -25,6 +28,7 @@ class Meta: name = factory.Faker('company') uuid = factory.Faker('uuid4') + class AIProviderFactory(factory.django.DjangoModelFactory): class Meta: model = AIProvider @@ -59,3 +63,14 @@ class Meta: 'enabled': True, 'sync_frequency': 'daily' }) + + +class NotificationProfileFactory(factory.django.DjangoModelFactory): + class Meta: + model = NotificationProfile + + owner = factory.SubFactory(UserFactory) + name = factory.Sequence(lambda n: f'Profile {n}') + type = 'slack' + config = factory.LazyAttribute(lambda o: {'webhookUrl': 'https://hooks.slack.com/services/T/B/test'}) + is_enabled = True diff --git a/backend/core/tests/test_notification_profiles.py b/backend/core/tests/test_notification_profiles.py new file mode 100644 index 0000000..ced803b --- /dev/null +++ b/backend/core/tests/test_notification_profiles.py @@ -0,0 +1,372 @@ +import pytest +from rest_framework import status + +from core.models import NotificationProfile, AppNotificationProfile +from core.tests.conftest import BaseAPITestCase +from core.tests.factories import UserFactory, ApplicationFactory, NotificationProfileFactory + + +@pytest.mark.api +class TestNotificationProfileAPI(BaseAPITestCase): + """Tests for /api/notification-profiles/ endpoints.""" + + def setUp(self): + super().setUp() + self.url = '/api/notification-profiles/' + + # ------------------------------------------------------------------ auth + def test_unauthenticated_returns_403(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # ------------------------------------------------------------------ list + def test_list_returns_only_own_profiles(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + NotificationProfileFactory(owner=user, name='Mine') + NotificationProfileFactory(owner=UserFactory(), name='Not Mine') + + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + names = [p['name'] for p in response.json()['results']] + self.assertIn('Mine', names) + self.assertNotIn('Not Mine', names) + + def test_list_includes_supported_providers(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('supported_providers', response.json()) + + # ----------------------------------------------------------------- create + def test_create_slack_profile(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + data = { + 'name': 'Slack Alerts', + 'type': 'slack', + 'config': {'webhookUrl': 'https://hooks.slack.com/services/abc/def/ghi'}, + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + body = response.json() + self.assertEqual(body['name'], 'Slack Alerts') + self.assertEqual(body['type'], 'slack') + # config is write-only — must not appear in response + self.assertNotIn('config', body) + # config_meta should indicate webhook is set + self.assertTrue(body['config_meta']['hasWebhookUrl']) + + def test_create_discord_profile(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + data = { + 'name': 'Discord Alerts', + 'type': 'discord', + 'config': {'webhookUrl': 'https://discord.com/api/webhooks/123/abc'}, + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(response.json()['config_meta']['hasWebhookUrl']) + + def test_create_email_profile(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + data = { + 'name': 'Email Alerts', + 'type': 'email', + 'config': {'email': 'alerts@example.com'}, + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(response.json()['config_meta']['hasEmail']) + + def test_create_sets_owner_to_requesting_user(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + data = { + 'name': 'My Profile', + 'type': 'slack', + 'config': {'webhookUrl': 'https://hooks.slack.com/services/x/y/z'}, + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json()['owner'], user.id) + + # --------------------------------------------------- create validation + def test_create_slack_with_wrong_domain_fails(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + data = { + 'name': 'Bad Slack', + 'type': 'slack', + 'config': {'webhookUrl': 'https://discord.com/api/webhooks/bad'}, + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_discord_with_wrong_domain_fails(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + data = { + 'name': 'Bad Discord', + 'type': 'discord', + 'config': {'webhookUrl': 'https://hooks.slack.com/services/bad'}, + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_email_with_invalid_format_fails(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + data = { + 'name': 'Bad Email', + 'type': 'email', + 'config': {'email': 'not-an-email'}, + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_create_with_missing_webhook_url_fails(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + data = {'name': 'No Webhook', 'type': 'slack', 'config': {}} + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + # ----------------------------------------- config never exposed in response + def test_raw_config_values_not_in_response(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + data = { + 'name': 'Secret Profile', + 'type': 'slack', + 'config': {'webhookUrl': 'https://hooks.slack.com/services/secret'}, + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + body = response.json() + self.assertNotIn('config', body) + self.assertNotIn('secret', str(body)) + + # ----------------------------------------------------------------- update + def test_patch_own_profile_name(self): + user = UserFactory() + self.client.force_authenticate(user=user) + profile = NotificationProfileFactory(owner=user, name='Old Name') + + response = self.client.patch( + f'{self.url}{profile.uuid}/', + {'name': 'New Name'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()['name'], 'New Name') + profile.refresh_from_db() + self.assertEqual(profile.name, 'New Name') + + def test_patch_updates_webhook_url(self): + user = UserFactory() + self.client.force_authenticate(user=user) + profile = NotificationProfileFactory(owner=user, type='slack') + + new_url = 'https://hooks.slack.com/services/new/url/here' + response = self.client.patch( + f'{self.url}{profile.uuid}/', + {'config': {'webhookUrl': new_url}}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + profile.refresh_from_db() + self.assertEqual(profile.config['webhookUrl'], new_url) + + def test_patch_other_users_profile_returns_404(self): + user_a = UserFactory() + user_b = UserFactory() + self.client.force_authenticate(user=user_a) + profile = NotificationProfileFactory(owner=user_b) + + response = self.client.patch( + f'{self.url}{profile.uuid}/', + {'name': 'Hacked'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + # ----------------------------------------------------------------- delete + def test_delete_own_profile(self): + user = UserFactory() + self.client.force_authenticate(user=user) + profile = NotificationProfileFactory(owner=user) + + response = self.client.delete(f'{self.url}{profile.uuid}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json()['detail'], 'deleted') + self.assertFalse(NotificationProfile.objects.filter(uuid=profile.uuid).exists()) + + def test_delete_other_users_profile_returns_404(self): + user_a = UserFactory() + user_b = UserFactory() + self.client.force_authenticate(user=user_a) + profile = NotificationProfileFactory(owner=user_b) + + response = self.client.delete(f'{self.url}{profile.uuid}/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertTrue(NotificationProfile.objects.filter(uuid=profile.uuid).exists()) + + # ----------------------------------------------------------------- toggle + def test_toggle_disables_enabled_profile(self): + user = UserFactory() + self.client.force_authenticate(user=user) + profile = NotificationProfileFactory(owner=user, is_enabled=True) + + response = self.client.post(f'{self.url}{profile.uuid}/toggle/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + profile.refresh_from_db() + self.assertFalse(profile.is_enabled) + + def test_toggle_enables_disabled_profile(self): + user = UserFactory() + self.client.force_authenticate(user=user) + profile = NotificationProfileFactory(owner=user, is_enabled=False) + + response = self.client.post(f'{self.url}{profile.uuid}/toggle/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + profile.refresh_from_db() + self.assertTrue(profile.is_enabled) + + # -------------------------------------------- config encrypted at rest + def test_config_is_encrypted_in_database(self): + user = UserFactory() + self.client.force_authenticate(user=user) + + webhook = 'https://hooks.slack.com/services/T/B/secret123' + self.client.post(self.url, { + 'name': 'Enc Test', + 'type': 'slack', + 'config': {'webhookUrl': webhook}, + }, format='json') + + profile = NotificationProfile.objects.get(name='Enc Test', owner=user) + + # ORM decrypts transparently + self.assertEqual(profile.config['webhookUrl'], webhook) + + # Raw DB value must be encrypted + from django.db import connection + with connection.cursor() as cursor: + cursor.execute( + 'SELECT config FROM core_notificationprofile WHERE id = %s', + [profile.id], + ) + raw = cursor.fetchone()[0] + self.assertNotIn('secret123', raw) + self.assertTrue(raw.startswith('gAAAAA')) + + +@pytest.mark.api +class TestAppNotificationUpdateAPI(BaseAPITestCase): + """Tests for /api/applications//app-notification-update/""" + + def _url(self, app_uuid): + return f'/api/applications/{app_uuid}/app-notification-update/' + + def test_link_profiles_to_app(self): + user = UserFactory() + self.client.force_authenticate(user=user) + app = ApplicationFactory(owner=user) + p1 = NotificationProfileFactory(owner=user) + p2 = NotificationProfileFactory(owner=user) + + response = self.client.patch( + self._url(app.uuid), + {'profile_uuids': [str(p1.uuid), str(p2.uuid)]}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(AppNotificationProfile.objects.filter(application=app).count(), 2) + + def test_link_replaces_existing(self): + user = UserFactory() + self.client.force_authenticate(user=user) + app = ApplicationFactory(owner=user) + p1 = NotificationProfileFactory(owner=user) + p2 = NotificationProfileFactory(owner=user) + + self.client.patch(self._url(app.uuid), {'profile_uuids': [str(p1.uuid)]}, format='json') + self.client.patch(self._url(app.uuid), {'profile_uuids': [str(p2.uuid)]}, format='json') + + linked = AppNotificationProfile.objects.filter(application=app) + self.assertEqual(linked.count(), 1) + self.assertEqual(linked.first().notification_profile, p2) + + def test_cannot_link_other_users_profile(self): + user = UserFactory() + self.client.force_authenticate(user=user) + app = ApplicationFactory(owner=user) + foreign_profile = NotificationProfileFactory(owner=UserFactory()) + + response = self.client.patch( + self._url(app.uuid), + {'profile_uuids': [str(foreign_profile.uuid)]}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # Foreign profile silently ignored — nothing linked + self.assertEqual(AppNotificationProfile.objects.filter(application=app).count(), 0) + + def test_cannot_update_other_users_app(self): + user_a = UserFactory() + user_b = UserFactory() + self.client.force_authenticate(user=user_a) + app_b = ApplicationFactory(owner=user_b) + profile = NotificationProfileFactory(owner=user_a) + + response = self.client.patch( + self._url(app_b.uuid), + {'profile_uuids': [str(profile.uuid)]}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_invalid_payload_returns_400(self): + user = UserFactory() + self.client.force_authenticate(user=user) + app = ApplicationFactory(owner=user) + + response = self.client.patch( + self._url(app.uuid), + {'profile_uuids': 'not-a-list'}, + format='json', + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_empty_list_unlinks_all(self): + user = UserFactory() + self.client.force_authenticate(user=user) + app = ApplicationFactory(owner=user) + profile = NotificationProfileFactory(owner=user) + + self.client.patch(self._url(app.uuid), {'profile_uuids': [str(profile.uuid)]}, format='json') + self.client.patch(self._url(app.uuid), {'profile_uuids': []}, format='json') + + self.assertEqual(AppNotificationProfile.objects.filter(application=app).count(), 0) + + def test_unauthenticated_returns_403(self): + app = ApplicationFactory() + response = self.client.patch(self._url(app.uuid), {'profile_uuids': []}, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/core/utils.py b/backend/core/utils.py index 9c19ce0..3df7d9c 100644 --- a/backend/core/utils.py +++ b/backend/core/utils.py @@ -41,11 +41,11 @@ def extract_and_merge_fields( def normalize_model_name_by_provider(model: str, provider: str) -> str: """ Normalize model name based on provider. - + Args: model: The model name to normalize provider: The AI provider name - + Returns: Normalized model name """ diff --git a/backend/core/views/notification_profile.py b/backend/core/views/notification_profile.py index 7df91f7..6099956 100644 --- a/backend/core/views/notification_profile.py +++ b/backend/core/views/notification_profile.py @@ -1,25 +1,33 @@ -from rest_framework import viewsets, status, permissions +from rest_framework import viewsets, permissions, status +from rest_framework.decorators import action from rest_framework.response import Response from core.models import NotificationProfile -from core.serializers import NotificationProfileSerializer +from core.serializers.notification_profiles import NotificationProfileSerializer +from core.consts import SUPPORTED_NOTIFICATION_PROVIDERS class NotificationProfileViewSet(viewsets.ModelViewSet): - queryset = NotificationProfile.objects.none() permission_classes = [permissions.IsAuthenticated] + serializer_class = NotificationProfileSerializer + lookup_field = 'uuid' + http_method_names = ['get', 'post', 'patch', 'delete'] def get_queryset(self): return NotificationProfile.objects.filter(owner=self.request.user) - def get_serializer_class(self): - return NotificationProfileSerializer + def list(self, request, *args, **kwargs): + response = super().list(request, *args, **kwargs) + response.data['supported_providers'] = SUPPORTED_NOTIFICATION_PROVIDERS + return response - def perform_create(self, serializer): - serializer.save(owner=self.request.user) + def destroy(self, request, *args, **kwargs): + self.get_object().delete() + return Response({'detail': 'deleted'}, status=status.HTTP_200_OK) - def update(self, request, *args, **kwargs): - return Response( - {"detail": "PUT method not allowed. Use PATCH instead."}, - status=status.HTTP_405_METHOD_NOT_ALLOWED - ) \ No newline at end of file + @action(detail=True, methods=['post'], url_path='toggle') + def toggle_enabled(self, request, uuid=None): + profile = self.get_object() + profile.is_enabled = not profile.is_enabled + profile.save(update_fields=['is_enabled']) + return Response(NotificationProfileSerializer(profile).data) diff --git a/frontend/components/App/AppNotificationConfiguration.vue b/frontend/components/App/AppNotificationConfiguration.vue index 795e1dd..63713a4 100644 --- a/frontend/components/App/AppNotificationConfiguration.vue +++ b/frontend/components/App/AppNotificationConfiguration.vue @@ -1,86 +1,170 @@ + + diff --git a/frontend/components/notification/NewNotificationProfile.vue b/frontend/components/notification/NewNotificationProfile.vue index 416bc1e..b82169f 100644 --- a/frontend/components/notification/NewNotificationProfile.vue +++ b/frontend/components/notification/NewNotificationProfile.vue @@ -22,12 +22,24 @@ - + + + +
+ Notification Type + +
+
+ + +
+
| null>( null, @@ -110,10 +122,9 @@ const notificationTypes = [ { label: 'Discord', value: 'discord' }, ] -const selectedNotificationType = ref(notificationTypes[0]) - const webhookPlaceholder = computed(() => { - switch (selectedNotificationType.value?.value) { + const currentType = form.values.type + switch (currentType) { case 'slack': return 'https://hooks.slack.com/services/...' case 'discord': @@ -123,26 +134,17 @@ const webhookPlaceholder = computed(() => { } }) -const [type] = defineField('type') - const createNewNotification = form.handleSubmit(async (values) => { try { await notificationProfileStore.create(values) newNotificationSlideOver.value?.closeSlide() toast.success(`Notification profile created`) - } catch (e: unknown) { - setBackendErrors(form, e.errors) + } catch (e: any) { + const message = notificationProfileStore.getBackendErrorMessage(e) + toast.error(message) } }) -watch( - selectedNotificationType, - (val) => { - type.value = val.value - }, - { immediate: true }, -) - const disabled = computed(() => !meta.value.valid ) diff --git a/frontend/components/notification/UpdateNotificationProfile.vue b/frontend/components/notification/UpdateNotificationProfile.vue index 740b87a..6c53483 100644 --- a/frontend/components/notification/UpdateNotificationProfile.vue +++ b/frontend/components/notification/UpdateNotificationProfile.vue @@ -2,8 +2,6 @@
@@ -46,17 +44,36 @@

+ + diff --git a/frontend/lib/errorHandler.ts b/frontend/lib/errorHandler.ts new file mode 100644 index 0000000..ee1b700 --- /dev/null +++ b/frontend/lib/errorHandler.ts @@ -0,0 +1,70 @@ +import { toast } from 'vue-sonner' +import { getErrorMessage, setBackendErrors } from './utils' + +interface StandardError { + success: false + error_type: string + message: string + details?: string + errors?: Record + status_code: number +} + +function isStandardError(error: any): error is StandardError { + return error && + typeof error === 'object' && + error.success === false && + error.error_type && + error.message +} + +function extractErrorMessage(error: StandardError): string { + if (error.errors) { + const firstField = Object.keys(error.errors)[0] + if (firstField) { + const fieldError = error.errors[firstField] + if (typeof fieldError === 'string') return fieldError + if (typeof fieldError === 'object' && fieldError !== null) { + const nestedField = Object.keys(fieldError)[0] + if (nestedField) return String(fieldError[nestedField]) + } + if (Array.isArray(fieldError)) return fieldError.join(', ') + } + } + return error.details || error.message +} + +export function showError(error: any, customMessage?: string) { + if (isStandardError(error)) { + toast.error(customMessage || extractErrorMessage(error)) + return + } + + if (error.errors?.config) { + if (typeof error.errors.config === 'object' && error.errors.config.webhookUrl) { + toast.error(customMessage || error.errors.config.webhookUrl) + return + } + if (typeof error.errors.config === 'string') { + toast.error(customMessage || error.errors.config) + return + } + if (Array.isArray(error.errors.config)) { + toast.error(customMessage || error.errors.config.join(', ')) + return + } + } + + if (error.errors?.type) { toast.error(customMessage || error.errors.type); return } + if (error.errors?.name) { toast.error(customMessage || error.errors.name); return } + if (typeof error.errors === 'string') { toast.error(customMessage || error.errors); return } + + toast.error(customMessage || getErrorMessage(error) || 'Operation failed') +} + +export function handleFormError(error: any, form: any, customMessage?: string) { + if (error.errors && form) { + setBackendErrors(form, error.errors) + } + showError(error, customMessage) +} diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 4a5a58c..4bc63ba 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -13,7 +13,7 @@ export type KBTableRow = { export interface SelectOption { label: string value: string - selected: boolean + selected?: boolean } export interface PaginatedResponse { diff --git a/frontend/middleware/init.global.ts b/frontend/middleware/init.global.ts index 419f902..b04dfc9 100644 --- a/frontend/middleware/init.global.ts +++ b/frontend/middleware/init.global.ts @@ -2,7 +2,7 @@ export default defineNuxtRouteMiddleware(async (to) => { const userStore = useUserStore(); if(!userStore.getToken?.value) return - const excludedRoutes = ['/settings', '/login', '/register', '/forgot-password', '/reset-password', '/verify-email'] + const excludedRoutes = ['/login', '/register', '/forgot-password', '/reset-password', '/verify-email'] const isExcludedRoute = excludedRoutes.some(route => to.path.startsWith(route)) if (isExcludedRoute) return @@ -19,20 +19,18 @@ export default defineNuxtRouteMiddleware(async (to) => { } const apps = appStore.applications - - const appToBeSelected = appId - ? apps.find((app) => app?.uuid === appId) - : apps[0] - - if (appToBeSelected) { - appStore.selectApplication(appToBeSelected) - - if (!chatroomStore.chatrooms.length) { - await chatroomStore.fetchChatrooms(appToBeSelected.uuid) - } - - if (chatroomId) { - await chatroomMessagesStore.selectChatroom(appToBeSelected.uuid, chatroomId) + if (appId) { + const appToBeSelected = apps.find((app) => app?.uuid === appId) + if (appToBeSelected) { + appStore.selectApplication(appToBeSelected) + + if (!chatroomStore.chatrooms.length) { + await chatroomStore.fetchChatrooms(appToBeSelected.uuid) + } + + if (chatroomId) { + await chatroomMessagesStore.selectChatroom(appToBeSelected.uuid, chatroomId) + } } } }) diff --git a/frontend/pages/settings/ai-providers.vue b/frontend/pages/settings/ai-providers.vue index 9f7efb8..8f2fda9 100644 --- a/frontend/pages/settings/ai-providers.vue +++ b/frontend/pages/settings/ai-providers.vue @@ -7,6 +7,17 @@
+ + + + | null>(null) const isDeleteDialogOpen = ref(false) diff --git a/frontend/pages/settings/notification-profile.vue b/frontend/pages/settings/notification-profile.vue index 765d5c9..1cd691b 100644 --- a/frontend/pages/settings/notification-profile.vue +++ b/frontend/pages/settings/notification-profile.vue @@ -1,15 +1,21 @@ diff --git a/frontend/stores/appModels.ts b/frontend/stores/appModels.ts index 77f05f7..d1b92cc 100644 --- a/frontend/stores/appModels.ts +++ b/frontend/stores/appModels.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { useHttpClient } from '@/composables/useHttpClient' -import type { LLMModel, LLMModelType } from '~/stores/model' +import type { LLMModel, LLMModelType } from '~/stores/configureApp' export const useAppModelStore = defineStore('appModel', { state: () => ({ diff --git a/frontend/stores/applications.ts b/frontend/stores/applications.ts index 16344ae..e53a627 100644 --- a/frontend/stores/applications.ts +++ b/frontend/stores/applications.ts @@ -39,8 +39,14 @@ export const useApplicationsStore = defineStore('applications', { const response = await httpGet('/applications/') this.applications = response.results - if (!this.selectedApplication && this.applications.length > 0) { - this.selectedApplication = this.applications[0] + if (this.applications.length > 0) { + const savedUuid = localStorage.getItem('selectedAppUuid') + const savedApp = savedUuid + ? this.applications.find(a => a.uuid === savedUuid) + : null + if (!this.selectedApplication) { + this.selectedApplication = savedApp ?? this.applications[0] + } } }, @@ -72,6 +78,7 @@ export const useApplicationsStore = defineStore('applications', { selectApplication(app: Application) { this.selectedApplication = app + localStorage.setItem('selectedAppUuid', app.uuid) }, }, }) diff --git a/frontend/stores/configureApp.ts b/frontend/stores/configureApp.ts index 2b111f6..6e503ce 100644 --- a/frontend/stores/configureApp.ts +++ b/frontend/stores/configureApp.ts @@ -1,8 +1,15 @@ import { defineStore } from 'pinia' import { useHttpClient } from '~/composables/useHttpClient' -import type { LLMModel } from '~/stores/model' import type { IntegrationTools, SupportedIntegrationsResponse, SupportedProviders } from '~/stores/integration' -import type { SelectOption } from '~/lib/types' + +export type LLMModelType = 'text' | 'embedding' + +export interface LLMModel { + uuid: string + name: string + model_type: LLMModelType + provider?: string +} export interface AvailableConfig { llm_models: LLMModel[] @@ -104,22 +111,19 @@ export const useAppConfigurationStore = defineStore('appConfiguration', { ) }, - async saveNotifications(profiles: SelectOption[]) { + async saveNotifications(profileUuids: string[]) { const appStore = useApplicationsStore() const app = appStore.selectedApplication - if (!app) return + if (!app) throw new Error('No application selected') const { httpPatch } = useHttpClient() const response = await httpPatch( `applications/${app.uuid}/app-notification-update/`, - { - profile_uuids: profiles.map((profile) => profile.value) - }, + { profile_uuids: profileUuids }, ) if (response?.notification_profiles) { this.notifications = response.notification_profiles } - return response }, }, diff --git a/frontend/stores/notificationProfile.ts b/frontend/stores/notificationProfile.ts index 29dc303..74ac4d2 100644 --- a/frontend/stores/notificationProfile.ts +++ b/frontend/stores/notificationProfile.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { useHttpClient } from '@/composables/useHttpClient' import type { PaginatedResponse } from '~/lib/types' +import { getErrorMessage } from '~/lib/utils' export type NotificationType = 'email' | 'slack' | 'discord' @@ -30,6 +31,24 @@ export const useNotificationProfileStore = defineStore('notificationProfiles', { }), actions: { + getBackendErrorMessage(error: any): string { + if (error.errors?.config) { + if (typeof error.errors.config === 'object' && error.errors.config.webhookUrl) { + return error.errors.config.webhookUrl + } else if (typeof error.errors.config === 'string') { + return error.errors.config + } else if (Array.isArray(error.errors.config)) { + return error.errors.config.join(', ') + } + } + + if (error.errors?.type) return error.errors.type + if (error.errors?.name) return error.errors.name + if (typeof error.errors === 'string') return error.errors + + return getErrorMessage(error) || 'Operation failed' + }, + async load() { const { httpGet } = useHttpClient() const res = await httpGet('/notification-profiles/') @@ -55,14 +74,19 @@ export const useNotificationProfileStore = defineStore('notificationProfiles', { this.profiles = [...this.profiles, response] return response }, - async delete(id: number | string) { + async delete(uuid: string) { const { httpDelete } = useHttpClient() - await httpDelete(`/notification-profiles/${id}/`) - this.profiles = this.profiles.filter((profile) => profile.id !== id) + try { + const response = await httpDelete<{detail: string}>(`/notification-profiles/${uuid}/`) + this.profiles = this.profiles.filter((profile) => profile.uuid !== uuid) + return response + } catch (error) { + throw error + } }, async update( - id: number | string, + uuid: string, updatedProfile: Partial, ) { const { httpPatch } = useHttpClient() @@ -79,10 +103,10 @@ export const useNotificationProfileStore = defineStore('notificationProfiles', { payload.config = updatedProfile.config } const response = await httpPatch( - `/notification-profiles/${id}/`, + `/notification-profiles/${uuid}/`, payload, ) - const index = this.profiles.findIndex((profile) => profile.id === id) + const index = this.profiles.findIndex((profile) => profile.uuid === uuid) if (index !== -1) { this.profiles[index] = { ...this.profiles[index], ...response } } diff --git a/polymorphic-dispatch.md b/polymorphic-dispatch.md new file mode 100644 index 0000000..c5d4618 --- /dev/null +++ b/polymorphic-dispatch.md @@ -0,0 +1,235 @@ +# Polymorphic Dispatch — OOP Essentials + +## The Problem: Rigid Code + +Imagine you have different kinds of things that behave similarly, but not exactly the same. +Without polymorphic dispatch, you end up with long `if/else` chains that grow every time you add a new type. + +```python +# fragile — every new animal means editing this function +def make_sound(animal_type): + if animal_type == 'dog': + print('woof') + elif animal_type == 'cat': + print('meow') + # ... add another elif for each animal +``` + +You don't need to know OOP theory — just notice the pain. + +--- + +## The Solution: A Promise (Contract) + +We want each object to own its behaviour. Polymorphism means "many forms" — we just need a common guarantee: every animal knows how to `speak`. + +In Python we agree on method names — no formal interface required. + +```python +# a "contract" — if you have a .speak() method, you're an animal +class Dog: + def speak(self): + print('woof') + +class Cat: + def speak(self): + print('meow') +``` + +Both objects dispatch polymorphically: the caller doesn't check type, it just calls `.speak()`. + +--- + +## Polymorphic Call = No Conditionals + +Write one function that works for any object that follows the contract. + +```python +def perform_speak(animal): + animal.speak() # dispatch — dynamic decision + +dog = Dog() +cat = Cat() + +perform_speak(dog) # woof +perform_speak(cat) # meow + +# add a new animal without touching existing code +class Bird: + def speak(self): + print('chirp') + +perform_speak(Bird()) # chirp +``` + +This is the heart of polymorphic dispatch: **same call, different behaviour**. + +--- + +## Classes and Inheritance (Clean at Scale) + +A base class makes the contract explicit and lets subclasses share logic. + +```python +class Animal: + def __init__(self, name): + self.name = name + + def speak(self): + raise NotImplementedError('subclass must implement') # "abstract" + +class Dog(Animal): + def speak(self): + print(f'{self.name} says woof') + +class Cat(Animal): + def speak(self): + print(f'{self.name} says meow') + +animals = [Dog('Bella'), Cat('Luna')] +for a in animals: + a.speak() +# Bella says woof +# Luna says meow +``` + +No type checks, no switches — adding a new `Cow` doesn't break anything. + +--- + +## Production-Ready: Payment Processors + +E-commerce: different payment gateways. Polymorphism keeps it rock solid. + +```python +class PayPalProcessor: + def process(self, amount): + return f'PayPal: charged ${amount}' + +class StripeProcessor: + def process(self, amount): + return f'Stripe: charged ${amount} (fee 2.9%)' + +class CashProcessor: + def process(self, amount): + return f'Cash: collect ${amount} (no fee)' + +# checkout — completely open to new processors +def checkout(payment_method, amount): + print(payment_method.process(amount)) + +checkout(PayPalProcessor(), 99) +checkout(StripeProcessor(), 45) +checkout(CashProcessor(), 20) +``` + +New `CryptoProcessor`? Just pass it. Zero changes in `checkout`. + +--- + +## Polymorphism Without Inheritance + +You don't even need a base class. As long as the method name matches, dispatch works. + +```python +class Logger: + def log(self, msg): + print(msg) + +class AlertSystem: + def log(self, msg): + print(f'ALERT: {msg}') + +def write(device, message): + device.log(message) # polymorphic dispatch + +write(Logger(), 'server started') # server started +write(AlertSystem(), 'CPU overload') # ALERT: CPU overload +``` + +--- + +## Real-World Example: Notification Provider Validation + +Our app supports multiple notification providers — email, Slack, Discord — each with its own validation rules. This is exactly the problem polymorphic dispatch solves. + +### Before — rigid, growing if/elif + +```python +# every new provider means editing this function +def validate_config_for_type(provider_type, config): + if provider_type == 'email': + email = config.get('email', '').strip() + if '@' not in email or '.' not in email.split('@')[-1]: + raise ValidationError(...) + + if provider_type in ('slack', 'discord'): + url = config.get('webhookUrl', '').strip() + if not url.startswith(('http://', 'https://')): + raise ValidationError(...) + if provider_type == 'slack' and 'hooks.slack.com' not in url: + raise ValidationError(...) + if provider_type == 'discord' and 'discord.com' not in url: + raise ValidationError(...) +``` + +### After — each provider owns its rules + +```python +# contract: every validator implements .validate(config) +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 ValidationError(...) + +class WebhookProviderValidator(BaseProviderValidator): + webhook_domain = None # subclasses set this + + def validate(self, config): + url = config.get('webhookUrl', '').strip() + if not url.startswith(('http://', 'https://')): + raise ValidationError(...) + if self.webhook_domain and self.webhook_domain not in url: + raise ValidationError(...) + +class SlackProviderValidator(WebhookProviderValidator): + webhook_domain = 'hooks.slack.com' + +class DiscordProviderValidator(WebhookProviderValidator): + webhook_domain = 'discord.com' + +# registry maps type → validator +_VALIDATOR_REGISTRY = { + 'email': EmailProviderValidator(), + 'slack': SlackProviderValidator(), + 'discord': DiscordProviderValidator(), +} + +# dispatcher — never changes +def validate_config_for_type(provider_type, config): + # ... shared guards (dict check, required fields) ... + validator = _VALIDATOR_REGISTRY.get(provider_type, BaseProviderValidator()) + validator.validate(config) # polymorphic dispatch — no if/elif on type +``` + +Adding Teams is two lines, nothing else changes: + +```python +class TeamsProviderValidator(WebhookProviderValidator): + webhook_domain = 'webhook.office.com' + +_VALIDATOR_REGISTRY['teams'] = TeamsProviderValidator() +``` + +--- + +## Your Polymorphic Toolkit + +1. Define a contract (method name + signature) — informally or via a base class +2. Implement the contract in each concrete class +3. Call the method without checking type — let Python dispatch it +4. Extend forever: new implementations, zero changes to existing code