mirror of
https://github.com/narrrl/holy-flavor-rater.git
synced 2026-11-03 16:41:27 +01:00
updated user features and added palette
This commit is contained in:
1 parent
03adde8928
commit
6b26b77e0e
11 files changed
+515
-140
No files matched your search
@@ -35,6 +35,7 @@ class Command(BaseCommand):
|
||||
'hydration': Category.objects.get_or_create(name='Hydration', defaults={'slug': 'hydration'})[0],
|
||||
'iced-tea': Category.objects.get_or_create(name='Iced Tea', defaults={'slug': 'iced-tea'})[0],
|
||||
'milkshake': Category.objects.get_or_create(name='Milkshake', defaults={'slug': 'milkshake'})[0],
|
||||
'packs': Category.objects.get_or_create(name='Packs and other', defaults={'slug': 'packs-and-other'})[0],
|
||||
}
|
||||
|
||||
created_count = 0
|
||||
@@ -43,14 +44,30 @@ class Command(BaseCommand):
|
||||
for p in products:
|
||||
title = p.get('title')
|
||||
tags = p.get('tags', [])
|
||||
product_type = p.get('product_type', '').lower()
|
||||
if isinstance(tags, str):
|
||||
tags = [t.strip() for t in tags.split(',')]
|
||||
|
||||
# Determine category
|
||||
category = None
|
||||
tags_lower = [t.lower() for t in tags]
|
||||
title_lower = title.lower()
|
||||
|
||||
# Check for Packs/Bundles/Samples first
|
||||
is_pack = False
|
||||
pack_keywords = ['bundle', 'pack', 'set', 'box', 'probe', 'sample', 'taster', 'shaker', 'starter']
|
||||
|
||||
if 'holy energy' in tags_lower or 'energy' in tags_lower:
|
||||
# Check title keywords
|
||||
if any(k in title_lower for k in pack_keywords):
|
||||
is_pack = True
|
||||
|
||||
# Check product type keywords
|
||||
if any(k in product_type for k in pack_keywords):
|
||||
is_pack = True
|
||||
|
||||
if is_pack:
|
||||
category = cat_map['packs']
|
||||
elif 'holy energy' in tags_lower or 'energy' in tags_lower:
|
||||
category = cat_map['energy']
|
||||
elif 'holy hydration' in tags_lower or 'hydration' in tags_lower:
|
||||
category = cat_map['hydration']
|
||||
@@ -59,8 +76,12 @@ class Command(BaseCommand):
|
||||
elif 'milkshake' in tags_lower:
|
||||
category = cat_map['milkshake']
|
||||
|
||||
# Skip if not a flavor product (e.g. merch, shakers)
|
||||
if not category or 'merch' in tags_lower or 'shaker' in tags_lower:
|
||||
# Skip only pure merch (clothing etc) if it's not a pack
|
||||
if not category:
|
||||
if 'merch' in tags_lower:
|
||||
continue
|
||||
# If we still don't have a category but it's a product, maybe put it in Packs/Other as fallback?
|
||||
# For now, let's skip to avoid clutter unless we are sure.
|
||||
continue
|
||||
|
||||
# Check availability
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-15 22:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0004_user_email_confirmation_code_user_pending_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='theme',
|
||||
field=models.CharField(choices=[('latte', 'Latte'), ('frappe', 'Frappé'), ('macchiato', 'Macchiato'), ('mocha', 'Mocha'), ('pink', 'Pastel Pink')], default='mocha', max_length=20),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.2 on 2026-02-15 22:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0005_alter_user_theme'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Reply',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.TextField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('rating', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='api.rating')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,7 @@ class User(AbstractUser):
|
||||
('frappe', 'Frappé'),
|
||||
('macchiato', 'Macchiato'),
|
||||
('mocha', 'Mocha'),
|
||||
('pink', 'Pastel Pink'),
|
||||
]
|
||||
theme = models.CharField(max_length=20, choices=THEME_CHOICES, default='mocha')
|
||||
pending_email = models.EmailField(max_length=254, blank=True, null=True)
|
||||
@@ -59,3 +60,15 @@ class Rating(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.flavor.name}: {self.score}"
|
||||
|
||||
class Reply(models.Model):
|
||||
user = models.ForeignKey(User, related_name='replies', on_delete=models.CASCADE)
|
||||
rating = models.ForeignKey(Rating, related_name='replies', on_delete=models.CASCADE)
|
||||
text = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Reply by {self.user.username} on {self.rating.id}"
|
||||
@@ -1,5 +1,5 @@
|
||||
from rest_framework import serializers
|
||||
from .models import User, Flavor, Category, Rating
|
||||
from .models import User, Flavor, Category, Rating, Reply
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
@@ -11,14 +11,23 @@ class CategorySerializer(serializers.ModelSerializer):
|
||||
model = Category
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
class ReplySerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Reply
|
||||
fields = ['id', 'user', 'rating', 'text', 'created_at']
|
||||
read_only_fields = ['rating']
|
||||
|
||||
class RatingSerializer(serializers.ModelSerializer):
|
||||
user = serializers.StringRelatedField(read_only=True)
|
||||
flavor_name = serializers.CharField(source='flavor.name', read_only=True)
|
||||
flavor_image = serializers.CharField(source='flavor.image_url', read_only=True)
|
||||
replies = ReplySerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Rating
|
||||
fields = ['id', 'user', 'flavor', 'flavor_name', 'flavor_image', 'score', 'comment', 'created_at']
|
||||
fields = ['id', 'user', 'flavor', 'flavor_name', 'flavor_image', 'score', 'comment', 'created_at', 'replies']
|
||||
|
||||
class FlavorSerializer(serializers.ModelSerializer):
|
||||
category_name = serializers.CharField(source='category.name', read_only=True)
|
||||
|
||||
+67
-45
@@ -4,11 +4,10 @@ from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from django.db.models import Avg
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from .models import User, Flavor, Category, Rating
|
||||
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer
|
||||
from .models import User, Flavor, Category, Rating, Reply
|
||||
from .serializers import UserSerializer, FlavorSerializer, CategorySerializer, RatingSerializer, ReplySerializer
|
||||
|
||||
class FlavorViewSet(viewsets.ModelViewSet):
|
||||
# ... (rest of FlavorViewSet)
|
||||
queryset = Flavor.objects.annotate(average_rating=Avg('ratings__score')).order_by('-average_rating')
|
||||
serializer_class = FlavorSerializer
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
@@ -28,12 +27,35 @@ class RatingViewSet(viewsets.ModelViewSet):
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# Ensure user can only have one rating per flavor
|
||||
flavor = serializer.validated_data['flavor']
|
||||
if Rating.objects.filter(user=self.request.user, flavor=flavor).exists():
|
||||
raise serializers.ValidationError('You have already rated this flavor.')
|
||||
serializer.save(user=self.request.user)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.action == 'list':
|
||||
return Rating.objects.select_related('user', 'flavor')
|
||||
return Rating.objects.filter(user=self.request.user)
|
||||
if self.action in ['list', 'retrieve']:
|
||||
return Rating.objects.select_related('user', 'flavor').prefetch_related('replies', 'replies__user')
|
||||
# Allow users to see only their own ratings when editing/deleting?
|
||||
# No, for detail views they need to find it.
|
||||
# The permission class IsOwnerOrReadOnly (custom) would be better,
|
||||
# but standard ViewSet logic handles "get_object" which we can protect.
|
||||
return Rating.objects.select_related('user', 'flavor').prefetch_related('replies', 'replies__user')
|
||||
|
||||
def check_object_permissions(self, request, obj):
|
||||
super().check_object_permissions(request, obj)
|
||||
if request.method in ['PUT', 'PATCH', 'DELETE'] and obj.user != request.user:
|
||||
self.permission_denied(request, message='You cannot edit/delete this rating.')
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
def reply(self, request, pk=None):
|
||||
rating = self.get_object()
|
||||
text = request.data.get('text')
|
||||
if not text:
|
||||
return Response({'error': 'Text is required'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
reply = Reply.objects.create(user=request.user, rating=rating, text=text)
|
||||
return Response(ReplySerializer(reply).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
class UserViewSet(viewsets.ModelViewSet):
|
||||
queryset = User.objects.all()
|
||||
@@ -105,45 +127,6 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
else:
|
||||
return Response({'error': 'Invalid verification code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def request_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
try:
|
||||
user = User.objects.get(email=email, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
send_mail(
|
||||
'Password Reset Request',
|
||||
f'Hi {user.username},\n\nYour password reset code is: {code}',
|
||||
'noreply@holyflavors.com',
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def complete_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
code = request.data.get('code')
|
||||
new_password = request.data.get('password')
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email, email_confirmation_code=code)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Password reset successful!'})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def me(self, request):
|
||||
serializer = self.get_serializer(request.user)
|
||||
@@ -217,6 +200,45 @@ class UserViewSet(viewsets.ModelViewSet):
|
||||
user.save()
|
||||
return Response({'status': 'Email confirmed', 'email': user.email})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def request_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
try:
|
||||
user = User.objects.get(email=email, is_active=True)
|
||||
except User.DoesNotExist:
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
import secrets
|
||||
import string
|
||||
code = ''.join(secrets.choice(string.digits) for _ in range(6))
|
||||
user.email_confirmation_code = code
|
||||
user.save()
|
||||
|
||||
send_mail(
|
||||
'Password Reset Request',
|
||||
f'Hi {user.username},\n\nYour password reset code is: {code}',
|
||||
'noreply@holyflavors.com',
|
||||
[email],
|
||||
fail_silently=False,
|
||||
)
|
||||
return Response({'status': 'If an account exists with this email, a reset code has been sent.'})
|
||||
|
||||
@action(detail=False, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def complete_password_reset(self, request):
|
||||
email = request.data.get('email')
|
||||
code = request.data.get('code')
|
||||
new_password = request.data.get('password')
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email, email_confirmation_code=code)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': 'Invalid email or code'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.email_confirmation_code = None
|
||||
user.save()
|
||||
return Response({'status': 'Password reset successful!'})
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def dashboard(self, request):
|
||||
user = request.user
|
||||
|
||||
Reference in new issue
Block a user