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
63 changes: 28 additions & 35 deletions chatbot/admin/company_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
from pydantic import ValidationError
from simple_history.admin import SimpleHistoryAdmin
from .generic_upload_admin import BatchUploadMixin
from chatbot.filter.admin_filter import (CompanyChatCompanyFilter, ChatSessionFilter, ProfileCityFilter,
ProfileStateFilter, ProfileCompanyChatFilter, ProfileEmailFilter)
from chatbot.filter.admin_filter import (CompanyChatCompanyFilter, ChatSessionFilter,
ProfileCompanyChatFilter, ProfileEmailFilter)
from chatbot.filter.custom_date_from_filter import CustomAdvanceDateFilter
from chatbot.models import Company, Profile, ProfileType, CompanyBot, CompanyChat, ChatSession, \
CompanyBotTypeChoices, Voice, VoiceProvider, VoiceType, ImageConfiguration, Flow
from chatbot.models import Company, Profile, ProfileType, CompanyBot, CompanyChat, CompanyChatFeedback, \
ChatSession, CompanyBotTypeChoices, Voice, VoiceProvider, VoiceType, ImageConfiguration, Flow
from chatbot.models.company_models import CompanyStateMachine
from chatbot.resources.resource import CompanyChatResource
from chatbot.resources.company_resource import ChatSessionResource
Expand Down Expand Up @@ -292,9 +292,30 @@ def changelist_view(self, request, extra_context=None):
duplicate_bot.short_description = "Duplicate selected bot"


class CompanyChatFeedbackInline(admin.TabularInline):
"""Read-only: feedback rows are created via the feedback API only and are never edited,
so admins can view the full history here but can't add/change/delete from this screen."""
model = CompanyChatFeedback
fk_name = 'company_chat'
extra = 0
fields = ('thumbs_up', 'thumbs_down', 'comment', 'created_at')
readonly_fields = fields
ordering = ('-created_at',)

def has_add_permission(self, request, obj=None):
return False

def has_change_permission(self, request, obj=None):
return False

def has_delete_permission(self, request, obj=None):
return False


@admin.register(CompanyChat)
class CompanyChatAdmin(ExportAllFieldsMixin, admin.ModelAdmin):
list_display = ('session', 'sender', 'receiver', 'message', 'translated_message', 'created_at', 'stage', 'status')
inlines = [CompanyChatFeedbackInline]
list_filter = (
CustomAdvanceDateFilter,
ProfileCompanyChatFilter,
Expand All @@ -313,44 +334,16 @@ class CompanyChatAdmin(ExportAllFieldsMixin, admin.ModelAdmin):
resource_class = CompanyChatResource

def get_queryset(self, request):
qs = super().get_queryset(request)
qs = super().get_queryset(request).prefetch_related('sender__company', 'receiver__company')
user_email = request.user.email
profile = Profile.objects.filter(email=user_email).select_related('company').first()
if request.user.is_superuser:
return qs.prefetch_related('sender__company', 'receiver__company')
return qs
elif profile and profile.profile_type == ProfileType.MODERATOR:
return qs.filter(
Q(sender__company=profile.company) | Q(receiver__company=profile.company)
).prefetch_related('sender__company', 'receiver__company')
return qs.filter(Q(sender__company=profile.company) | Q(receiver__company=profile.company))
else:
return qs.none()

def get_search_results(self, request, queryset, search_term):
queryset, use_distinct = super().get_search_results(request, queryset, search_term)

user_email = request.user.email
profile = Profile.objects.filter(email=user_email).select_related('company').first()
if not request.user.is_superuser and profile and profile.profile_type == ProfileType.MODERATOR:
if profile.company:
queryset = queryset.filter(
Q(sender__company=profile.company) | Q(receiver__company=profile.company)
).prefetch_related('sender__company', 'receiver__company')
return queryset, use_distinct

def get_list_filter(self, request):
user = request.user
user_email = request.user.email
profile = Profile.objects.filter(email=user_email).select_related('company').first()
if not user.is_superuser and profile and profile.profile_type == ProfileType.MODERATOR:
company = profile.company
if company.slug == 'fmch':
return (CustomAdvanceDateFilter, ProfileCompanyChatFilter,
ProfileEmailFilter, 'session', ProfileCityFilter, ProfileStateFilter, 'message_type')
if company.slug == 'tfistaging':
return (CustomAdvanceDateFilter, ProfileCompanyChatFilter,
ProfileEmailFilter, 'session', CompanyChatCompanyFilter, 'stage')
return super().get_list_filter(request)


@admin.register(ChatSession)
class ChatSessionAdmin(ExportAllFieldsMixin, admin.ModelAdmin):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by Django 5.2 on 2026-07-30 09:49

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('chatbot', '0088_remove_botvernacular_bot_vernacu_company_483975_idx_and_more'),
]

operations = [
migrations.AlterField(
model_name='chatsession',
name='language',
field=models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada'), ('te', 'Telugu'), ('or', 'Odia'), ('ta', 'Tamil')], default='en', max_length=1000),
),
migrations.AlterField(
model_name='story',
name='language',
field=models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada'), ('te', 'Telugu'), ('or', 'Odia'), ('ta', 'Tamil')], default='en', max_length=1000),
),
migrations.AlterField(
model_name='storytranslation',
name='language',
field=models.CharField(choices=[('en', 'English'), ('hi', 'Hindi'), ('kn', 'Kannada'), ('te', 'Telugu'), ('or', 'Odia'), ('ta', 'Tamil')], max_length=10),
),
migrations.CreateModel(
name='CompanyChatFeedback',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('thumbs_up', models.BooleanField(default=False, help_text='True if the user gave a positive rating in this submission.')),
('thumbs_down', models.BooleanField(default=False, help_text='True if the user gave a negative rating in this submission. Cannot be True at the same time as thumbs_up (enforced in the serializer).')),
('comment', models.TextField(blank=True, help_text='Optional free-text feedback typed by the user.', null=True)),
('created_at', models.DateTimeField(auto_now_add=True, help_text='When this feedback was submitted. Immutable — also used to determine the current state (latest row wins) and submission order.')),
('company_chat', models.ForeignKey(help_text='The bot response (CompanyChat row) this feedback is about.', on_delete=django.db.models.deletion.CASCADE, related_name='feedbacks', to='chatbot.companychat')),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['company_chat', '-created_at'], name='chatbot_com_company_f623f2_idx')],
},
),
]
37 changes: 37 additions & 0 deletions chatbot/models/company_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,43 @@ def save(self, *args, **kwargs):
super(CompanyChat, self).save(*args, **kwargs)


class CompanyChatFeedback(models.Model):
"""
A single feedback submission (thumbs up/down + optional comment) for a bot response.
Rows are append-only — never updated — so the full history is preserved and the
most recent row (by created_at) represents the current state.
"""
company_chat = models.ForeignKey(
CompanyChat, related_name='feedbacks', on_delete=models.CASCADE,
help_text='The bot response (CompanyChat row) this feedback is about.'
)
thumbs_up = models.BooleanField(
default=False, help_text='True if the user gave a positive rating in this submission.'
)
thumbs_down = models.BooleanField(
default=False,
help_text='True if the user gave a negative rating in this submission. '
'Cannot be True at the same time as thumbs_up (enforced in the serializer).'
)
comment = models.TextField(
null=True, blank=True, help_text='Optional free-text feedback typed by the user.'
)
created_at = models.DateTimeField(
auto_now_add=True,
help_text='When this feedback was submitted. Immutable — also used to determine '
'the current state (latest row wins) and submission order.'
)

class Meta:
ordering = ['-created_at']
indexes = [
models.Index(fields=['company_chat', '-created_at']),
]

def __str__(self):
return f'Feedback #{self.id} for CompanyChat #{self.company_chat_id}'


class Voice(models.Model):
"""
Defines a text-to-speech voice configuration for a company bot.
Expand Down
36 changes: 36 additions & 0 deletions chatbot/parsers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import codecs

from django.conf import settings
from rest_framework.exceptions import ParseError
from rest_framework.parsers import JSONParser
from rest_framework.utils import json


def _reject_duplicate_keys(pairs):
seen = set()
for key, _ in pairs:
if key in seen:
raise ParseError(f'Duplicate key "{key}" in request body.')
seen.add(key)
return dict(pairs)


class StrictJSONParser(JSONParser):
"""Like DRF's JSONParser, but rejects a JSON object with duplicate keys instead of
silently keeping only the last occurrence."""

def parse(self, stream, media_type=None, parser_context=None):
parser_context = parser_context or {}
encoding = parser_context.get('encoding', settings.DEFAULT_CHARSET)
try:
decoded_stream = codecs.getreader(encoding)(stream)
parse_constant = json.strict_constant if self.strict else None
return json.load(
decoded_stream,
object_pairs_hook=_reject_duplicate_keys,
parse_constant=parse_constant,
)
except ParseError:
raise
except ValueError as exc:
raise ParseError('JSON parse error - %s' % str(exc))
170 changes: 170 additions & 0 deletions chatbot/scripts/export_chats_by_userid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Standalone shell_plus script.
#
# Usage: open `python manage.py shell_plus`, paste this ENTIRE file, then run:
#
# export_chats_by_userid(
# input_csv='/path/to/phnNumberData.csv',
# output_xlsx='/path/to/output.xlsx',
# )
#
# It's wrapped in exec("""...""") on purpose — some shell_plus setups (plain
# Python REPL inside tmux/screen, no bracketed paste) mis-parse a pasted
# multi-line script because blank lines inside a function body look like
# "end of block" to the incremental parser. Wrapping the whole body in a
# single string literal sidesteps that: the REPL just buffers lines until
# the closing triple-quote, then exec() runs it as one unit.
#
# Input CSV must have columns: Name, Phone Number, User Id
# (header names are matched case-insensitively, spaces/underscores ignored).
#
# For every input row, this fetches chatbot_profile by userid, then every
# CompanyChat where that profile is sender or receiver, and writes one xlsx
# row per chat message (all CompanyChat fields except other_params), with
# the original Name/Phone Number/User Id repeated on every row so it can be
# filtered/pivoted in Google Sheets. Rows whose userid doesn't map to a
# profile, or has no chats, still get exactly one output row with the chat
# columns left blank — a mapping_status column says why.

exec("""
import csv
import pandas as pd
from django.db.models import Q
from chatbot.models import Profile
from chatbot.models.company_models import CompanyChat

COMPANY_CHAT_FIELDS = [
'id', 'session', 'message', 'translated_message', 'chunks',
'sender_id', 'receiver_id', 'created_at', 'updated_at', 'status',
'feedback', 'source', 'source_msg_id', 'whatsapp_message_id',
'message_type', 'stage', 'file_url', 'audio_file',
] # deliberately excludes other_params

EMPTY_CHAT_ROW = {f'chat_{f}': '' for f in COMPANY_CHAT_FIELDS}
EMPTY_CHAT_ROW['chat_file_url_https'] = ''


def _s3_to_https(value):
if value and value.startswith('s3://'):
return 'https://' + value[len('s3://'):]
return ''
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _read_input_rows(input_csv):
with open(input_csv, newline='', encoding='utf-8-sig') as f:
reader = csv.DictReader(f)
header_map = {}
for raw_name in reader.fieldnames or []:
key = raw_name.strip().lower().replace(' ', '').replace('_', '')
header_map[key] = raw_name

name_col = header_map.get('name')
phone_col = header_map.get('phonenumber') or header_map.get('phone')
userid_col = header_map.get('userid')

if not (name_col and phone_col and userid_col):
raise ValueError(
f"Could not find Name/Phone Number/User Id columns. "
f"Found headers: {reader.fieldnames}"
)

rows = []
for row in reader:
rows.append({
'csv_name': (row.get(name_col) or '').strip(),
'csv_phone_number': (row.get(phone_col) or '').strip(),
'csv_user_id': (row.get(userid_col) or '').strip(),
})
return rows


def _chat_row(chat):
out = {}
for field in COMPANY_CHAT_FIELDS:
value = getattr(chat, field)
out[f'chat_{field}'] = str(value) if value not in (None, '') else ''
out['chat_file_url_https'] = _s3_to_https(out['chat_file_url'])
return out


def export_chats_by_userid(input_csv, output_xlsx):
input_rows = _read_input_rows(input_csv)
print(f"[export_chats_by_userid] read {len(input_rows)} input rows from {input_csv}")

output_rows = []
stats = {'no_userid_in_csv': 0, 'profile_not_found': 0, 'profile_found_no_chats': 0, 'profile_found': 0}

for i, row in enumerate(input_rows, start=1):
userid = row['csv_user_id']
base = {
'name': row['csv_name'],
'phone_number': row['csv_phone_number'],
'user_id': userid,
}

if not userid:
stats['no_userid_in_csv'] += 1
output_rows.append({**base, 'mapping_status': 'no_userid_in_csv',
'profile_id': '', 'profile_first_name': '', 'profile_last_name': '',
'profile_phone': '', 'profile_email': '', 'profile_status': '',
**EMPTY_CHAT_ROW})
continue

profile = Profile.objects.filter(userid=userid).first()

if not profile:
stats['profile_not_found'] += 1
output_rows.append({**base, 'mapping_status': 'profile_not_found',
'profile_id': '', 'profile_first_name': '', 'profile_last_name': '',
'profile_phone': '', 'profile_email': '', 'profile_status': '',
**EMPTY_CHAT_ROW})
continue

profile_cols = {
'profile_id': profile.id,
'profile_first_name': profile.first_name or '',
'profile_last_name': profile.last_name or '',
'profile_phone': profile.phone or '',
'profile_email': profile.email or '',
'profile_status': profile.status or '',
}

chats = CompanyChat.objects.filter(
Q(sender_id=profile.id) | Q(receiver_id=profile.id)
).order_by('session', 'created_at')

if not chats.exists():
stats['profile_found_no_chats'] += 1
output_rows.append({**base, 'mapping_status': 'profile_found_no_chats',
**profile_cols, **EMPTY_CHAT_ROW})
continue

stats['profile_found'] += 1
for chat in chats:
output_rows.append({**base, 'mapping_status': 'profile_found',
**profile_cols, **_chat_row(chat)})

if i % 50 == 0:
print(f"[export_chats_by_userid] processed {i}/{len(input_rows)} input rows")

df = pd.DataFrame(output_rows)
df.to_excel(output_xlsx, index=False, engine='openpyxl')

print("[export_chats_by_userid] done")
print(f" input rows : {len(input_rows)}")
print(f" no_userid_in_csv : {stats['no_userid_in_csv']}")
print(f" profile_not_found : {stats['profile_not_found']}")
print(f" profile_found_no_chats: {stats['profile_found_no_chats']}")
print(f" profile_found : {stats['profile_found']}")
print(f" output rows (xlsx) : {len(output_rows)}")
print(f" written to : {output_xlsx}")

return df
""")

# ============================================================================
# Run — edit the paths below before pasting into shell_plus
# ============================================================================
export_chats_by_userid(
input_csv='/home/ubuntu/user_sample_data.csv',
output_xlsx='/home/ubuntu/sample_output.xlsx',
)
Loading