added profile comment notification

This commit is contained in:
nvrl committed 2026-02-18 05:48:12 +01:00
1 parent 6cf8b42c38
commit 4318eed372
6 files changed
+75 -29

No files matched your search

@@ -0,0 +1,24 @@
# Generated by Django 6.0.2 on 2026-02-18 04:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0019_alter_flavor_options'),
]
operations = [
migrations.AddField(
model_name='notification',
name='profile_comment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.profilecomment'),
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply'), ('profile_comment', 'Profile Comment')], max_length=20),
),
]
+2
View File
@@ -128,6 +128,7 @@ class Notification(models.Model):
('mention', 'Mention'),
('ticket_new', 'New Ticket'),
('ticket_reply', 'Ticket Reply'),
('profile_comment', 'Profile Comment'),
]
recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE)
actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
@@ -135,6 +136,7 @@ class Notification(models.Model):
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)
profile_comment = models.ForeignKey('ProfileComment', on_delete=models.CASCADE, null=True, blank=True)
is_read = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
+7 -1
View File
@@ -179,10 +179,11 @@ class NotificationSerializer(serializers.ModelSerializer):
actor_avatar = serializers.SerializerMethodField()
flavor_name = serializers.SerializerMethodField()
flavor_id = serializers.SerializerMethodField()
profile_owner_username = serializers.SerializerMethodField()
class Meta:
model = Notification
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'ticket', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
fields = ['id', 'actor_username', 'actor_avatar', 'notification_type', 'rating', 'reply', 'ticket', 'profile_comment', 'profile_owner_username', 'is_read', 'created_at', 'flavor_name', 'flavor_id']
def get_actor_avatar(self, obj):
if obj.actor.avatar:
@@ -205,3 +206,8 @@ class NotificationSerializer(serializers.ModelSerializer):
if obj.reply:
return obj.reply.rating.flavor.id
return None
def get_profile_owner_username(self, obj):
if obj.profile_comment:
return obj.profile_comment.profile_owner.username
return None
+10
View File
@@ -381,6 +381,16 @@ class UserViewSet(viewsets.ModelViewSet):
author=request.user,
text=text
)
# Notify profile owner
if profile_owner != request.user:
Notification.objects.create(
recipient=profile_owner,
actor=request.user,
notification_type='profile_comment',
profile_comment=comment
)
return Response(ProfileCommentSerializer(comment, context={'request': request}).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['delete'], url_path='delete_comment/(?P<comment_id>[^/.]+)', permission_classes=[permissions.IsAuthenticated])
+12 -8
View File
@@ -73,13 +73,14 @@ interface Notification {
id: number;
actor_username: string;
actor_avatar: string | null;
notification_type: 'reply' | 'mention';
notification_type: 'reply' | 'mention' | 'ticket_new' | 'ticket_reply' | 'profile_comment';
rating: number | null;
reply: number | null;
is_read: boolean;
created_at: string;
flavor_name: string | null;
flavor_id: number | null;
profile_owner_username: string | null;
}
const GlobalSearch = () => {
@@ -494,16 +495,13 @@ const App: React.FC = () => {
</Box>
) : (
notifications.map(n => {
let message = "";
if (n.notification_type === 'reply') message = `replied to your review on ${n.flavor_name}`;
else if (n.notification_type === 'mention') message = `mentioned you on ${n.flavor_name}`;
else if (n.notification_type === 'ticket_new') message = t('community.notifTicketNew');
else if (n.notification_type === 'ticket_reply') message = user?.is_superuser ? t('community.notifTicketReplyAdmin') : t('community.notifTicketReply');
const handleClick = () => {
if (n.notification_type.startsWith('ticket')) {
handleNotificationClick(n);
navigate('/support');
} else if (n.notification_type === 'profile_comment') {
handleNotificationClick(n);
navigate(`/profile/${user?.username}`);
} else {
handleNotificationClick(n);
}
@@ -530,7 +528,13 @@ const App: React.FC = () => {
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ lineHeight: 1.4 }}>
<strong>{n.actor_username}</strong> {message}
<strong>{n.actor_username}</strong> {
n.notification_type === 'reply' ? `replied to your review on ${n.flavor_name}` :
n.notification_type === 'mention' ? `mentioned you on ${n.flavor_name}` :
n.notification_type === 'profile_comment' ? `left a message on your guestbook` :
n.notification_type === 'ticket_new' ? t('community.notifTicketNew') :
user?.is_superuser ? t('community.notifTicketReplyAdmin') : t('community.notifTicketReply')
}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(n.created_at)}
+20 -20
View File
@@ -255,38 +255,38 @@ const Dashboard: React.FC = () => {
{/* TAB 0: MY REVIEWS */}
{activeTab === 0 && (
<Box>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ mb: 4 }}>
<Box sx={{ flexGrow: 1 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ mb: 4, alignItems: { xs: 'stretch', md: 'center' } }}>
<Box sx={{ flexGrow: 1, minWidth: { md: 300 } }}>
<TextField
fullWidth placeholder={t('dashboard.searchRated')}
fullWidth size="small" placeholder={t('dashboard.searchRated')}
value={ratedSearch} onChange={e => setRatedSearch(e.target.value)}
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment> } }}
sx={{ bgcolor: 'background.paper', borderRadius: 1, '& .MuiOutlinedInput-root': { borderRadius: 1 } }}
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> } }}
sx={{ bgcolor: 'background.paper', borderRadius: 1, '& .MuiOutlinedInput-root': { borderRadius: 1, height: 40 } }}
/>
</Box>
<Stack direction="row" spacing={1} sx={{ overflowX: 'auto', pb: 1, '&::-webkit-scrollbar': { display: 'none' } }}>
<Stack direction="row" spacing={0.8} sx={{ overflowX: 'auto', py: 0.5, '&::-webkit-scrollbar': { display: 'none' }, flexShrink: 0 }}>
{ratedCategories.map(cat => (
<Chip
key={cat} label={cat === 'All' ? t('nav.home').replace('Home', 'All') : t(`categories.${data.my_ratings.find(r => r.category_name === cat)?.category_slug}`, { defaultValue: cat })}
onClick={() => setRatedCategory(cat)}
color={ratedCategory === cat ? 'primary' : 'default'}
variant={ratedCategory === cat ? 'filled' : 'outlined'}
sx={{ fontWeight: 'bold', height: 40, px: 1, borderRadius: 1 }}
sx={{ fontWeight: 'bold', height: 32, px: 0.5, borderRadius: 1, fontSize: '0.8rem' }}
/>
))}
</Stack>
<Stack direction="row" spacing={1} sx={{ bgcolor: 'background.paper', p: 0.5, borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" spacing={0.5} sx={{ bgcolor: 'background.paper', p: 0.5, borderRadius: 1, border: '1px solid', borderColor: 'divider', height: 40, alignItems: 'center' }}>
<Button
size="small" variant={ratedSort === 'date' ? 'contained' : 'text'}
onClick={() => setRatedSort('date')}
sx={{ textTransform: 'none', fontWeight: 'bold', borderRadius: 0.8 }}
sx={{ textTransform: 'none', fontWeight: 'bold', borderRadius: 0.8, height: 30, px: 2, fontSize: '0.8rem' }}
>
{t('dashboard.date')}
</Button>
<Button
size="small" variant={ratedSort === 'rating' ? 'contained' : 'text'}
onClick={() => setRatedSort('rating')}
sx={{ textTransform: 'none', fontWeight: 'bold', borderRadius: 0.8 }}
sx={{ textTransform: 'none', fontWeight: 'bold', borderRadius: 0.8, height: 30, px: 2, fontSize: '0.8rem' }}
>
{t('dashboard.rating')}
</Button>
@@ -417,38 +417,38 @@ const Dashboard: React.FC = () => {
{activeTab === 1 && (
<Box>
{/* Filter Controls */}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} sx={{ mb: 4 }}>
<Box sx={{ flexGrow: 1 }}>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ mb: 4, alignItems: { xs: 'stretch', md: 'center' } }}>
<Box sx={{ flexGrow: 1, minWidth: { md: 300 } }}>
<TextField
fullWidth placeholder={t('dashboard.searchMissing')}
fullWidth size="small" placeholder={t('dashboard.searchMissing')}
value={exploreSearch} onChange={e => setExploreSearch(e.target.value)}
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment> } }}
sx={{ bgcolor: 'background.paper', borderRadius: 1, '& .MuiOutlinedInput-root': { borderRadius: 1 } }}
slotProps={{ input: { startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> } }}
sx={{ bgcolor: 'background.paper', borderRadius: 1, '& .MuiOutlinedInput-root': { borderRadius: 1, height: 40 } }}
/>
</Box>
<Stack direction="row" spacing={1} sx={{ overflowX: 'auto', pb: 1, '&::-webkit-scrollbar': { display: 'none' } }}>
<Stack direction="row" spacing={0.8} sx={{ overflowX: 'auto', py: 0.5, '&::-webkit-scrollbar': { display: 'none' }, flexShrink: 0 }}>
{exploreCategories.map(cat => (
<Chip
key={cat} label={cat === 'All' ? t('nav.home').replace('Home', 'All') : t(`categories.${data.missing_flavors.find(f => f.category_name === cat)?.category_slug}`, { defaultValue: cat })}
onClick={() => setExploreCategory(cat)}
color={exploreCategory === cat ? 'primary' : 'default'}
variant={exploreCategory === cat ? 'filled' : 'outlined'}
sx={{ fontWeight: 'bold', height: 40, px: 1, borderRadius: 1 }}
sx={{ fontWeight: 'bold', height: 32, px: 0.5, borderRadius: 1, fontSize: '0.8rem' }}
/>
))}
</Stack>
<Stack direction="row" spacing={1} sx={{ bgcolor: 'background.paper', p: 0.5, borderRadius: 1, border: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" spacing={0.5} sx={{ bgcolor: 'background.paper', p: 0.5, borderRadius: 1, border: '1px solid', borderColor: 'divider', height: 40, alignItems: 'center' }}>
<Button
size="small" variant={exploreSort === 'community' ? 'contained' : 'text'}
onClick={() => setExploreSort('community')}
sx={{ textTransform: 'none', fontWeight: 'bold', borderRadius: 0.8 }}
sx={{ textTransform: 'none', fontWeight: 'bold', borderRadius: 0.8, height: 30, px: 2, fontSize: '0.8rem' }}
>
{t('dashboard.communityRating')}
</Button>
<Button
size="small" variant={exploreSort === 'circle' ? 'contained' : 'text'}
onClick={() => setExploreSort('circle')}
sx={{ textTransform: 'none', fontWeight: 'bold', borderRadius: 0.8 }}
sx={{ textTransform: 'none', fontWeight: 'bold', borderRadius: 0.8, height: 30, px: 2, fontSize: '0.8rem' }}
>
{t('dashboard.circleRating')}
</Button>