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
37 changes: 32 additions & 5 deletions flowbit-backend/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,17 +220,44 @@ class SupportMessageInline(admin.TabularInline):

@admin.register(SupportCase)
class SupportCaseAdmin(admin.ModelAdmin):
list_display = ('subject', 'created_by', 'status', 'closed_by', 'last_message_at', 'created_at')
list_filter = ('status', 'created_at', 'closed_at')
search_fields = ('subject', 'created_by__username', 'messages__body')
list_display = ('subject', 'intake_type', 'requester_display', 'status', 'closed_by', 'last_message_at', 'created_at')
list_filter = ('intake_type', 'status', 'created_at', 'closed_at')
search_fields = (
'subject',
'created_by__username',
'requester_name',
'requester_login_identifier',
'messages__body',
)
inlines = [SupportMessageInline]

@admin.display(description='Requester')
def requester_display(self, obj):
if obj.intake_type == SupportCase.INTAKE_LOGIN_HELP:
return obj.requester_name or obj.requester_login_identifier
return obj.created_by


@admin.register(SupportMessage)
class SupportMessageAdmin(admin.ModelAdmin):
list_display = ('support_case', 'sender', 'created_at')
list_display = ('support_case', 'sender_display', 'created_at')
list_filter = ('created_at',)
search_fields = ('support_case__subject', 'sender__username', 'body')
search_fields = (
'support_case__subject',
'sender__username',
'support_case__requester_name',
'support_case__requester_login_identifier',
'body',
)

@admin.display(description='Sender')
def sender_display(self, obj):
if (
obj.support_case.intake_type == SupportCase.INTAKE_LOGIN_HELP
and obj.support_case.created_by_id == obj.sender_id
):
return obj.support_case.requester_name or obj.support_case.requester_login_identifier
return obj.sender


@admin.register(Collaborator)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Generated by Django 4.2.28 on 2026-05-26 02:29

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("core", "0022_emailverificationtoken"),
]

operations = [
migrations.AddField(
model_name="supportcase",
name="intake_type",
field=models.CharField(
choices=[("STANDARD", "Standard"), ("LOGIN_HELP", "Login help")],
default="STANDARD",
max_length=24,
),
),
migrations.AddField(
model_name="supportcase",
name="requester_login_identifier",
field=models.CharField(blank=True, default="", max_length=160),
),
migrations.AddField(
model_name="supportcase",
name="requester_name",
field=models.CharField(blank=True, default="", max_length=160),
),
]
15 changes: 14 additions & 1 deletion flowbit-backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1700,6 +1700,13 @@ def _create_period_event_notification(


class SupportCase(models.Model):
INTAKE_STANDARD = 'STANDARD'
INTAKE_LOGIN_HELP = 'LOGIN_HELP'
INTAKE_TYPE_CHOICES = (
(INTAKE_STANDARD, 'Standard'),
(INTAKE_LOGIN_HELP, 'Login help'),
)

STATUS_OPEN = 'OPEN'
STATUS_CLOSED = 'CLOSED'
STATUS_CHOICES = (
Expand All @@ -1713,6 +1720,9 @@ class SupportCase(models.Model):
related_name='support_cases',
)
subject = models.CharField(max_length=160)
intake_type = models.CharField(max_length=24, choices=INTAKE_TYPE_CHOICES, default=INTAKE_STANDARD)
requester_name = models.CharField(max_length=160, blank=True, default='')
requester_login_identifier = models.CharField(max_length=160, blank=True, default='')
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_OPEN)
closed_at = models.DateTimeField(null=True, blank=True)
closed_by = models.ForeignKey(
Expand All @@ -1727,9 +1737,12 @@ class SupportCase(models.Model):
updated_at = models.DateTimeField(auto_now=True)

class Meta:
ordering = ['-last_message_at', '-updated_at', '-id']
ordering = ['-created_at', '-id']

def __str__(self):
if self.intake_type == self.INTAKE_LOGIN_HELP:
identity = self.requester_name or self.requester_login_identifier or self.created_by.username
return f"{self.subject} ({identity})"
return f"{self.subject} ({self.created_by.username})"

def close(self, closed_by=None, save=True):
Expand Down
65 changes: 63 additions & 2 deletions flowbit-backend/core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,7 @@ class NotificationBroadcastSerializer(serializers.Serializer):


class SupportMessageSerializer(serializers.ModelSerializer):
sender_username = serializers.CharField(source='sender.username', read_only=True)
sender_username = serializers.SerializerMethodField()
sender_full_name = serializers.SerializerMethodField()
sender_role = serializers.SerializerMethodField()
is_admin_sender = serializers.SerializerMethodField()
Expand All @@ -1097,10 +1097,31 @@ class Meta:
]
read_only_fields = fields

def _is_login_help_requester_message(self, obj):
return (
obj.support_case.intake_type == SupportCase.INTAKE_LOGIN_HELP
and obj.support_case.created_by_id == obj.sender_id
)

def get_sender_username(self, obj):
if self._is_login_help_requester_message(obj):
return obj.support_case.requester_login_identifier
if obj.sender_id is None:
return ''
return obj.sender.username

def get_sender_full_name(self, obj):
if self._is_login_help_requester_message(obj):
return obj.support_case.requester_name or obj.support_case.requester_login_identifier
if obj.sender_id is None:
return 'Login help requester'
return obj.sender.get_full_name().strip() or obj.sender.username

def get_sender_role(self, obj):
if self._is_login_help_requester_message(obj):
return 'login_help'
if obj.sender_id is None:
return ''
profile = getattr(obj.sender, 'profile', None)
return getattr(profile, 'role', '')

Expand All @@ -1110,7 +1131,7 @@ def get_is_admin_sender(self, obj):


class SupportCaseSerializer(serializers.ModelSerializer):
created_by_username = serializers.CharField(source='created_by.username', read_only=True)
created_by_username = serializers.SerializerMethodField()
created_by_full_name = serializers.SerializerMethodField()
created_by_role = serializers.SerializerMethodField()
closed_by_username = serializers.CharField(source='closed_by.username', read_only=True, allow_null=True)
Expand All @@ -1121,6 +1142,9 @@ class Meta:
fields = [
'id',
'subject',
'intake_type',
'requester_name',
'requester_login_identifier',
'status',
'created_by',
'created_by_username',
Expand All @@ -1137,10 +1161,19 @@ class Meta:
]
read_only_fields = fields

def get_created_by_username(self, obj):
if obj.intake_type == SupportCase.INTAKE_LOGIN_HELP:
return obj.requester_login_identifier or obj.created_by.username
return obj.created_by.username

def get_created_by_full_name(self, obj):
if obj.intake_type == SupportCase.INTAKE_LOGIN_HELP:
return obj.requester_name or obj.requester_login_identifier or obj.created_by.username
return obj.created_by.get_full_name().strip() or obj.created_by.username

def get_created_by_role(self, obj):
if obj.intake_type == SupportCase.INTAKE_LOGIN_HELP:
return 'login_help'
profile = getattr(obj.created_by, 'profile', None)
return getattr(profile, 'role', '')

Expand Down Expand Up @@ -1172,6 +1205,34 @@ def validate_message(self, value):
return value


class PublicLoginHelpCaseCreateSerializer(serializers.Serializer):
login_identifier = serializers.CharField(max_length=160)
requester_name = serializers.CharField(max_length=160, required=False, allow_blank=True)
subject = serializers.CharField(max_length=160)
message = serializers.CharField()

def validate_login_identifier(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError('Username or email is required.')
return value

def validate_requester_name(self, value):
return value.strip()

def validate_subject(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError('Subject is required.')
return value

def validate_message(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError('Message is required.')
return value


class SupportCaseReplySerializer(serializers.Serializer):
message = serializers.CharField()

Expand Down
76 changes: 76 additions & 0 deletions flowbit-backend/core/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -2513,6 +2513,31 @@ def test_user_support_case_list_is_scoped_to_owner(self):
subjects = {item['subject'] for item in response.data}
self.assertEqual(subjects, {'User One Case'})

def test_support_cases_are_listed_latest_activity_first(self):
earlier_case = SupportCase.objects.create(
created_by=self.user_one,
subject='Earlier Case',
last_message_at=timezone.now() - timezone.timedelta(hours=2),
)
later_case = SupportCase.objects.create(
created_by=self.user_one,
subject='Later Case',
last_message_at=timezone.now() - timezone.timedelta(hours=1),
)
SupportMessage.objects.create(support_case=earlier_case, sender=self.user_one, body='Earlier message')
SupportMessage.objects.create(support_case=later_case, sender=self.user_one, body='Later message')

later_case.last_message_at = timezone.now() - timezone.timedelta(minutes=10)
later_case.save(update_fields=['last_message_at', 'updated_at'])
earlier_case.last_message_at = timezone.now()
earlier_case.save(update_fields=['last_message_at', 'updated_at'])

self.client.force_authenticate(user=self.user_one)
response = self.client.get('/api/support-cases/')

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual([item['subject'] for item in response.data], ['Earlier Case', 'Later Case'])

def test_admin_can_view_all_support_cases(self):
case_one = SupportCase.objects.create(
created_by=self.user_one,
Expand Down Expand Up @@ -2559,6 +2584,57 @@ def test_user_can_create_support_case_with_initial_message(self):
).exists()
)

def test_public_login_help_case_can_be_created_without_authentication(self):
response = self.client.post('/api/support-cases/login-help/', {
'login_identifier': 'locked.user',
'requester_name': 'Locked User',
'subject': 'Cannot log in',
'message': 'I cannot access my account after several attempts.',
}, format='json')

self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data['message'], 'Your login-help case has been sent to the admin.')
support_case = SupportCase.objects.get(subject='Cannot log in')
self.assertEqual(support_case.intake_type, SupportCase.INTAKE_LOGIN_HELP)
self.assertEqual(support_case.requester_name, 'Locked User')
self.assertEqual(support_case.requester_login_identifier, 'locked.user')
self.assertEqual(support_case.created_by.username, '_login_help_intake')
self.assertEqual(support_case.messages.count(), 1)
self.assertEqual(support_case.messages.first().sender.username, '_login_help_intake')
self.assertEqual(response.data['case']['created_by_full_name'], 'Locked User')
self.assertEqual(response.data['case']['created_by_username'], 'locked.user')
self.assertTrue(
UserNotification.objects.filter(
recipient=self.admin_user,
title='New login help case',
).exists()
)
audit_entry = AuditLog.objects.get(action='support.login_help_case_created')
self.assertIsNone(audit_entry.user)

def test_public_login_help_does_not_open_authenticated_support_case_routes(self):
response = self.client.get('/api/support-cases/')

self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)

def test_admin_case_detail_shows_requester_identity_for_login_help_message(self):
self.client.post('/api/support-cases/login-help/', {
'login_identifier': 'locked.user',
'requester_name': 'Locked User',
'subject': 'Cannot log in',
'message': 'I cannot access my account after several attempts.',
}, format='json')
support_case = SupportCase.objects.get(subject='Cannot log in')

self.client.force_authenticate(user=self.admin_user)
response = self.client.get(f'/api/support-cases/{support_case.id}/')

self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['created_by_full_name'], 'Locked User')
self.assertEqual(response.data['created_by_username'], 'locked.user')
self.assertEqual(response.data['messages'][0]['sender_full_name'], 'Locked User')
self.assertEqual(response.data['messages'][0]['sender_username'], 'locked.user')

def test_case_can_be_replied_closed_and_reopened_by_both_sides(self):
support_case = SupportCase.objects.create(
created_by=self.user_one,
Expand Down
2 changes: 2 additions & 0 deletions flowbit-backend/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
VerifyEmailView,
ResendVerificationView,
ResetPasswordConfirmView,
PublicLoginHelpCaseCreateView,
TicketListView,
TicketDetailView,
TicketRefundView,
Expand All @@ -51,6 +52,7 @@
router.register(r'repeat-tickets', RepeatTicketViewSet, basename='repeat-ticket')

urlpatterns = [
path('support-cases/login-help/', PublicLoginHelpCaseCreateView.as_view(), name='support-case-login-help'),
# All router endpoints (ledgers, identifiers, transactions, overflows)
path('', include(router.urls)),

Expand Down
Loading
Loading