mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
added profile comment notification
This commit is contained in:
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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user