fixed ticket view

This commit is contained in:
nvrl committed 2026-02-18 03:36:56 +01:00
1 parent 20e9d8ba1f
commit f0f0a93e0b
6 files changed
+119 -29

No files matched your search

+2
View File
@@ -124,12 +124,14 @@ class Notification(models.Model):
TYPE_CHOICES = [
('reply', 'Reply'),
('mention', 'Mention'),
('ticket', 'Ticket Update'),
]
recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE)
actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
notification_type = models.CharField(max_length=10, choices=TYPE_CHOICES)
rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True)
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True)
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
+12 -2
View File
@@ -119,13 +119,23 @@ class TicketMessageSerializer(serializers.ModelSerializer):
class TicketSerializer(serializers.ModelSerializer):
username = serializers.CharField(source='user.username', read_only=True)
user_email = serializers.EmailField(source='user.email', read_only=True)
user_avatar = serializers.SerializerMethodField()
messages = TicketMessageSerializer(many=True, read_only=True)
class Meta:
model = Ticket
fields = ['id', 'user', 'username', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
fields = ['id', 'user', 'username', 'user_email', 'user_avatar', 'subject', 'description', 'status', 'created_at', 'updated_at', 'messages']
read_only_fields = ['status', 'user']
def get_user_avatar(self, obj):
if obj.user.avatar:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.user.avatar.url)
return obj.user.avatar.url
return None
class AdminUserListSerializer(serializers.ModelSerializer):
ips = serializers.SerializerMethodField()
@@ -155,7 +165,7 @@ class NotificationSerializer(serializers.ModelSerializer):
class Meta:
model = Notification
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'ticket', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
def get_actor_avatar(self, obj):
if obj.actor.avatar:
+32 -1
View File
@@ -576,7 +576,16 @@ class TicketViewSet(viewsets.ModelViewSet):
return Ticket.objects.filter(user=self.request.user).prefetch_related('messages', 'messages__user').order_by('-updated_at')
def perform_create(self, serializer):
serializer.save(user=self.request.user)
ticket = serializer.save(user=self.request.user)
# Notify all superusers about new ticket
admins = User.objects.filter(is_superuser=True).exclude(pk=self.request.user.pk)
for admin in admins:
Notification.objects.create(
recipient=admin,
actor=self.request.user,
notification_type='ticket',
ticket=ticket
)
@action(detail=True, methods=['post'])
def add_message(self, request, pk=None):
@@ -586,6 +595,28 @@ class TicketViewSet(viewsets.ModelViewSet):
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
msg = TicketMessage.objects.create(ticket=ticket, user=request.user, text=text)
# Notification logic
if request.user.is_superuser:
# Notify the user who opened the ticket
if ticket.user != request.user:
Notification.objects.create(
recipient=ticket.user,
actor=request.user,
notification_type='ticket',
ticket=ticket
)
else:
# Notify all superusers
admins = User.objects.filter(is_superuser=True).exclude(pk=request.user.pk)
for admin in admins:
Notification.objects.create(
recipient=admin,
actor=request.user,
notification_type='ticket',
ticket=ticket
)
return Response(TicketMessageSerializer(msg).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAdminUser])
+9
View File
@@ -7,6 +7,7 @@ import {
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import EmailIcon from '@mui/icons-material/Email';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import api from '../api';
@@ -86,6 +87,14 @@ const AdminDashboard: React.FC = () => {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
</Button>
<Typography variant={isMobile ? "h4" : "h3"} sx={{ fontWeight: 'bold', mb: 4 }}>{t('admin.title')}</Typography>
<Grid container spacing={isMobile ? 2 : 3} sx={{ mb: 6 }}>
+1 -1
View File
@@ -121,7 +121,7 @@ const AdminUserDetail: React.FC = () => {
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate('/admin-panel')}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
+63 -25
View File
@@ -2,12 +2,14 @@ import React, { useState, useEffect } from 'react';
import {
Container, Typography, Box, Card, CardContent, Button,
TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress,
IconButton
IconButton, Avatar
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import api from '../api';
import { useTitle } from '../hooks/useTitle';
import { formatDate } from '../utils/date';
@@ -22,7 +24,10 @@ interface Message {
interface Ticket {
id: number;
user: number;
username: string;
user_email: string;
user_avatar: string | null;
subject: string;
description: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed';
@@ -32,6 +37,7 @@ interface Ticket {
const Support: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
useTitle(t('support.title'));
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
@@ -122,6 +128,14 @@ const Support: React.FC = () => {
return (
<Container maxWidth="md" sx={{ py: 4 }}>
<Button
variant="outlined"
startIcon={<ArrowBackIcon />}
onClick={() => navigate(-1)}
sx={{ mb: 4, borderRadius: 2 }}
>
{t('common.back')}
</Button>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 4 }}>
<Typography variant="h3" sx={{ fontWeight: 'bold' }}>{t('support.title')}</Typography>
<Button
@@ -167,40 +181,62 @@ const Support: React.FC = () => {
<CardContent sx={{ p: 0 }}>
<Box
onClick={() => setExpandedId(expandedId === ticket.id ? null : ticket.id)}
sx={{ p: 2, cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', '&:hover': { bgcolor: 'action.hover' } }}
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{ticket.subject}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{ticket.subject}</Typography>
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
{currentUser?.is_superuser && (
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main' }}>
({ticket.username})
</Typography>
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Typography>
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
</Stack>
</Box>
<Stack direction="row" spacing={1} alignItems="center">
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
{currentUser?.is_superuser && (
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</Stack>
{currentUser?.is_superuser && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 1, p: 1, borderRadius: 2, bgcolor: alpha('#000', 0.03) }}>
<Avatar src={ticket.user_avatar || undefined} sx={{ width: 24, height: 24, fontSize: '0.7rem' }}>
{ticket.username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
{ticket.username} • <span style={{ opacity: 0.7 }}>{ticket.user_email}</span>
</Typography>
</Box>
<Button
size="small"
onClick={(e) => { e.stopPropagation(); navigate(`/profile/${ticket.username}`); }}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
>
View Profile
</Button>
<Button
size="small"
onClick={(e) => { e.stopPropagation(); navigate(`/admin-panel/user/${ticket.user}`); }}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
>
Admin User
</Button>
</Box>
)}
</Box>
<Collapse in={expandedId === ticket.id}>
<Divider />
<Box sx={{ p: 2, bgcolor: (theme) => alpha(theme.palette.background.default, 0.5) }}>
{currentUser?.is_superuser && (
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
<Stack direction="row" spacing={1} sx={{ mb: 2, overflowX: 'auto', pb: 1 }}>
{['open', 'in_progress', 'resolved', 'closed'].map((s) => (
<Button
key={s} size="small" variant={ticket.status === s ? "contained" : "outlined"}
onClick={() => handleUpdateStatus(ticket.id, s)}
color={getStatusColor(s) as any}
sx={{ textTransform: 'none', borderRadius: 4, fontSize: '0.7rem' }}
sx={{ textTransform: 'none', borderRadius: 4, fontSize: '0.7rem', flexShrink: 0 }}
>
{t(`support.${s}`)}
</Button>
@@ -208,7 +244,7 @@ const Support: React.FC = () => {
</Stack>
)}
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-wrap', mb: 3 }}>
<Typography variant="body2" paragraph sx={{ whiteSpace: 'pre-wrap', mb: 3, bgcolor: 'background.paper', p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
{ticket.description}
</Typography>
@@ -216,17 +252,18 @@ const Support: React.FC = () => {
{ticket.messages.map((msg: Message) => (
<Box key={msg.id} sx={{
alignSelf: msg.is_admin ? 'flex-start' : 'flex-end',
maxWidth: '80%',
maxWidth: '85%',
p: 1.5,
borderRadius: 2,
bgcolor: msg.is_admin ? 'primary.main' : 'action.selected',
color: msg.is_admin ? 'white' : 'inherit'
color: msg.is_admin ? 'white' : 'inherit',
boxShadow: msg.is_admin ? 2 : 0
}}>
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}>
{msg.username} {msg.is_admin && '(Support)'}
</Typography>
<Typography variant="body2">{msg.text}</Typography>
<Typography variant="caption" sx={{ opacity: 0.7 }}>{formatDate(msg.created_at)}</Typography>
<Typography variant="body2" sx={{ my: 0.5 }}>{msg.text}</Typography>
<Typography variant="caption" sx={{ opacity: 0.7, display: 'block', textAlign: 'right' }}>{formatDate(msg.created_at)}</Typography>
</Box>
))}
</Stack>
@@ -237,6 +274,7 @@ const Support: React.FC = () => {
fullWidth size="small" placeholder={t('community.writeReply')}
value={replyText[ticket.id] || ''}
onChange={(e) => setReplyText({ ...replyText, [ticket.id]: e.target.value })}
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleReply(ticket.id)}
/>
<IconButton color="primary" onClick={() => handleReply(ticket.id)} disabled={!replyText[ticket.id]}>
<SendIcon />