From 9aa7c01efe44b029e761511924e9698c40387a13 Mon Sep 17 00:00:00 2001 From: Sicherhaven Date: Tue, 7 Apr 2026 12:56:25 +0530 Subject: [PATCH] feat(favorites): add EventLike model, favorites API, and notifications module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EventLike model (user × event unique constraint, indexed) - contributed_by field on Event (EVT ID or email of community contributor) - Favorites API endpoints: toggle-like, my-likes, my-liked-events - Notifications app wired into main urls.py at /api/notifications/ - accounts migration 0014_merge_0013 (resolves split 0013 branches) - requirements.txt updated --- accounts/migrations/0014_merge_0013.py | 13 ++ eventify/urls.py | 1 + .../migrations/0011_event_contributed_by.py | 73 +++++++++ events/migrations/0012_eventlike.py | 38 +++++ events/models.py | 28 ++++ mobile_api/urls.py | 8 + mobile_api/views/favorites.py | 146 ++++++++++++++++++ notifications/__init__.py | 0 notifications/admin.py | 10 ++ notifications/apps.py | 6 + notifications/models.py | 25 +++ notifications/urls.py | 8 + notifications/views.py | 85 ++++++++++ requirements.txt | 1 + 14 files changed, 442 insertions(+) create mode 100644 accounts/migrations/0014_merge_0013.py create mode 100644 events/migrations/0011_event_contributed_by.py create mode 100644 events/migrations/0012_eventlike.py create mode 100644 mobile_api/views/favorites.py create mode 100644 notifications/__init__.py create mode 100644 notifications/admin.py create mode 100644 notifications/apps.py create mode 100644 notifications/models.py create mode 100644 notifications/urls.py create mode 100644 notifications/views.py diff --git a/accounts/migrations/0014_merge_0013.py b/accounts/migrations/0014_merge_0013.py new file mode 100644 index 0000000..e54fd3f --- /dev/null +++ b/accounts/migrations/0014_merge_0013.py @@ -0,0 +1,13 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + """Merge migration to resolve conflicting 0013 migrations.""" + + dependencies = [ + ('accounts', '0013_merge_eventify_id'), + ('accounts', '0013_user_district_changed_at'), + ] + + operations = [ + ] diff --git a/eventify/urls.py b/eventify/urls.py index 420cdcc..9aaa74f 100644 --- a/eventify/urls.py +++ b/eventify/urls.py @@ -36,6 +36,7 @@ urlpatterns = [ path('banking/', include('banking_operations.urls')), path('api/', include('mobile_api.urls')), path('api/v1/', include('admin_api.urls')), + path('api/notifications/', include('notifications.urls')), # path('web-api/', include('web_api.urls')), path('summernote/', include('django_summernote.urls')), diff --git a/events/migrations/0011_event_contributed_by.py b/events/migrations/0011_event_contributed_by.py new file mode 100644 index 0000000..dc37dd5 --- /dev/null +++ b/events/migrations/0011_event_contributed_by.py @@ -0,0 +1,73 @@ +""" +Add contributed_by field to Event and backfill from overloaded source field. + +The admin dashboard stores community contributor identifiers (EVT-XXXXXXXX or email) +in the source field. This migration: +1. Adds a dedicated contributed_by CharField +2. Copies user identifiers from source → contributed_by +3. Normalizes source back to its intended choices ('eventify', 'community', 'partner') +""" + +from django.db import migrations, models + + +def backfill_contributed_by(apps, schema_editor): + """Move user identifiers from source to contributed_by.""" + Event = apps.get_model('events', 'Event') + + STANDARD_SOURCES = {'eventify', 'community', 'partner', 'eventify_team', 'official', ''} + + for event in Event.objects.all().iterator(): + source_val = (event.source or '').strip() + changed = False + + # User identifier: contains @ (email) or starts with EVT- (eventifyId) + if source_val and source_val not in STANDARD_SOURCES and not source_val.startswith('partner:'): + event.contributed_by = source_val + event.source = 'community' + changed = True + + # Normalize eventify_team → eventify + elif source_val == 'eventify_team': + event.source = 'eventify' + changed = True + + # Normalize official → eventify + elif source_val == 'official': + event.source = 'eventify' + changed = True + + if changed: + event.save(update_fields=['source', 'contributed_by']) + + +def reverse_backfill(apps, schema_editor): + """Reverse: move contributed_by back to source.""" + Event = apps.get_model('events', 'Event') + for event in Event.objects.exclude(contributed_by__isnull=True).exclude(contributed_by='').iterator(): + event.source = event.contributed_by + event.contributed_by = None + event.save(update_fields=['source', 'contributed_by']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0010_merge_20260324_1443'), + ] + + operations = [ + # Step 1: Add the field + migrations.AddField( + model_name='event', + name='contributed_by', + field=models.CharField( + blank=True, + help_text='Eventify ID (EVT-XXXXXXXX) or email of the community contributor', + max_length=100, + null=True, + ), + ), + # Step 2: Backfill data + migrations.RunPython(backfill_contributed_by, reverse_backfill), + ] diff --git a/events/migrations/0012_eventlike.py b/events/migrations/0012_eventlike.py new file mode 100644 index 0000000..da67851 --- /dev/null +++ b/events/migrations/0012_eventlike.py @@ -0,0 +1,38 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0011_event_contributed_by'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='EventLike', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('event', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='likes', + to='events.event', + )), + ('user', models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='event_likes', + to=settings.AUTH_USER_MODEL, + )), + ], + options={ + 'unique_together': {('user', 'event')}, + }, + ), + migrations.AddIndex( + model_name='eventlike', + index=models.Index(fields=['user', '-created_at'], name='events_even_user_id_created_idx'), + ), + ] diff --git a/events/models.py b/events/models.py index 21c5ed5..cf7f625 100644 --- a/events/models.py +++ b/events/models.py @@ -58,6 +58,11 @@ class Event(models.Model): is_featured = models.BooleanField(default=False, help_text='Show this event in the featured section') is_top_event = models.BooleanField(default=False, help_text='Show this event in the Top Events section') + contributed_by = models.CharField( + max_length=100, blank=True, null=True, + help_text='Eventify ID (EVT-XXXXXXXX) or email of the community contributor', + ) + def __str__(self): return f"{self.name} ({self.start_date})" @@ -71,3 +76,26 @@ class EventImages(models.Model): return f"{self.event_image}" +class EventLike(models.Model): + user = models.ForeignKey( + 'accounts.User', + on_delete=models.CASCADE, + related_name='event_likes' + ) + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name='likes' + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'event') + indexes = [ + models.Index(fields=['user', '-created_at']), + ] + + def __str__(self): + return f"{self.user.email} likes {self.event.name}" + + diff --git a/mobile_api/urls.py b/mobile_api/urls.py index a852982..7a28a8c 100644 --- a/mobile_api/urls.py +++ b/mobile_api/urls.py @@ -2,6 +2,7 @@ from django.urls import path from .views import * from mobile_api.views.user import ScheduleCallView from mobile_api.views.reviews import ReviewSubmitView, MobileReviewListView, ReviewHelpfulView, ReviewFlagView +from mobile_api.views.favorites import ToggleLikeView, MyLikedIdsView, MyLikedEventsView from ad_control.views import ConsumerFeaturedEventsView, ConsumerTopEventsView @@ -39,3 +40,10 @@ urlpatterns += [ path('reviews/helpful', ReviewHelpfulView.as_view()), path('reviews/flag', ReviewFlagView.as_view()), ] + +# Favorites URLs +urlpatterns += [ + path('events/like/', ToggleLikeView.as_view()), + path('events/my-likes/', MyLikedIdsView.as_view()), + path('events/my-liked-events/', MyLikedEventsView.as_view()), +] diff --git a/mobile_api/views/favorites.py b/mobile_api/views/favorites.py new file mode 100644 index 0000000..b830872 --- /dev/null +++ b/mobile_api/views/favorites.py @@ -0,0 +1,146 @@ +from django.views import View +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.http import JsonResponse +from django.core.paginator import Paginator + +from events.models import Event, EventLike, EventImages +from mobile_api.utils import validate_token_and_get_user +from eventify_logger.services import log + + +def _serialize_liked_event(event): + """Serialize an Event for the liked-events list.""" + primary_img = EventImages.objects.filter( + event=event, is_primary=True + ).first() + if not primary_img: + primary_img = EventImages.objects.filter(event=event).first() + + return { + 'id': event.id, + 'title': event.title or event.name, + 'image': primary_img.event_image.url if primary_img else '', + 'date': str(event.start_date) if event.start_date else None, + 'location': event.place or '', + 'venue': event.venue_name or '', + 'event_type': event.event_type.event_type if event.event_type else '', + 'event_status': event.event_status, + } + + +@method_decorator(csrf_exempt, name='dispatch') +class ToggleLikeView(View): + """POST /api/events/like/ — toggle like on/off for an event.""" + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + event_id = data.get('event_id') + if not event_id: + return JsonResponse( + {'status': 'error', 'message': 'event_id is required'}, + status=400 + ) + + try: + event = Event.objects.get(pk=event_id) + except Event.DoesNotExist: + return JsonResponse( + {'status': 'error', 'message': 'Event not found'}, + status=404 + ) + + like, created = EventLike.objects.get_or_create(user=user, event=event) + if not created: + like.delete() + return JsonResponse({'status': 'success', 'liked': False}) + + return JsonResponse({'status': 'success', 'liked': True}) + + except Exception as e: + log("error", "ToggleLikeView exception", request=request, + logger_data={"error": str(e)}) + return JsonResponse( + {'status': 'error', 'message': 'An unexpected server error occurred.'}, + status=500 + ) + + +@method_decorator(csrf_exempt, name='dispatch') +class MyLikedIdsView(View): + """POST /api/events/my-likes/ — return all liked event IDs for the user.""" + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + liked_ids = list( + EventLike.objects.filter(user=user) + .values_list('event_id', flat=True) + ) + return JsonResponse({'status': 'success', 'liked_event_ids': liked_ids}) + + except Exception as e: + log("error", "MyLikedIdsView exception", request=request, + logger_data={"error": str(e)}) + return JsonResponse( + {'status': 'error', 'message': 'An unexpected server error occurred.'}, + status=500 + ) + + +@method_decorator(csrf_exempt, name='dispatch') +class MyLikedEventsView(View): + """POST /api/events/my-liked-events/ — paginated liked events with full data.""" + + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request) + if error_response: + return error_response + + page = int(data.get('page', 1)) + page_size = min(int(data.get('page_size', 20)), 50) + + # Event IDs liked by this user, newest first + liked_event_ids = list( + EventLike.objects.filter(user=user) + .order_by('-created_at') + .values_list('event_id', flat=True) + ) + + # Preserve ordering from liked_event_ids + from django.db.models import Case, When, IntegerField + ordering = Case( + *[When(pk=pk, then=pos) for pos, pk in enumerate(liked_event_ids)], + output_field=IntegerField() + ) + events_qs = Event.objects.filter(id__in=liked_event_ids).order_by(ordering) + + paginator = Paginator(events_qs, page_size) + page_obj = paginator.get_page(page) + + events_data = [_serialize_liked_event(e) for e in page_obj] + + return JsonResponse({ + 'status': 'success', + 'events': events_data, + 'total': paginator.count, + 'page': page, + 'page_size': page_size, + 'has_next': page_obj.has_next(), + }) + + except Exception as e: + log("error", "MyLikedEventsView exception", request=request, + logger_data={"error": str(e)}) + return JsonResponse( + {'status': 'error', 'message': 'An unexpected server error occurred.'}, + status=500 + ) diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/admin.py b/notifications/admin.py new file mode 100644 index 0000000..82d7872 --- /dev/null +++ b/notifications/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin +from .models import Notification + + +@admin.register(Notification) +class NotificationAdmin(admin.ModelAdmin): + list_display = ('title', 'user', 'notification_type', 'is_read', 'created_at') + list_filter = ('notification_type', 'is_read', 'created_at') + search_fields = ('title', 'message', 'user__email') + readonly_fields = ('created_at',) diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..001b4f9 --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'notifications' diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000..af582a9 --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,25 @@ +from django.db import models +from accounts.models import User + + +class Notification(models.Model): + NOTIFICATION_TYPES = [ + ('event', 'Event'), + ('promo', 'Promotion'), + ('system', 'System'), + ('booking', 'Booking'), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='notifications') + title = models.CharField(max_length=255) + message = models.TextField() + notification_type = models.CharField(max_length=20, choices=NOTIFICATION_TYPES, default='system') + is_read = models.BooleanField(default=False) + action_url = models.URLField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"{self.notification_type}: {self.title} → {self.user.email}" diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 0000000..e5bb854 --- /dev/null +++ b/notifications/urls.py @@ -0,0 +1,8 @@ +from django.urls import path +from .views import NotificationListView, NotificationMarkReadView, NotificationCountView + +urlpatterns = [ + path('list/', NotificationListView.as_view(), name='notification_list'), + path('mark-read/', NotificationMarkReadView.as_view(), name='notification_mark_read'), + path('count/', NotificationCountView.as_view(), name='notification_count'), +] diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000..39534e7 --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,85 @@ +import json +from django.views.decorators.csrf import csrf_exempt +from django.http import JsonResponse +from django.utils.decorators import method_decorator +from django.views import View +from mobile_api.utils import validate_token_and_get_user +from eventify_logger.services import log +from .models import Notification + + +@method_decorator(csrf_exempt, name='dispatch') +class NotificationListView(View): + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + page = int(data.get('page', 1)) + page_size = int(data.get('page_size', 20)) + offset = (page - 1) * page_size + + notifications = Notification.objects.filter(user=user)[offset:offset + page_size] + total = Notification.objects.filter(user=user).count() + + items = [{ + 'id': n.id, + 'title': n.title, + 'message': n.message, + 'notification_type': n.notification_type, + 'is_read': n.is_read, + 'action_url': n.action_url or '', + 'created_at': n.created_at.isoformat(), + } for n in notifications] + + return JsonResponse({ + 'status': 'success', + 'notifications': items, + 'total': total, + 'page': page, + 'page_size': page_size, + }) + except Exception as e: + log("error", "NotificationListView error", request=request, logger_data={"error": str(e)}) + return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500) + + +@method_decorator(csrf_exempt, name='dispatch') +class NotificationMarkReadView(View): + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + mark_all = data.get('mark_all', False) + notification_id = data.get('notification_id') + + if mark_all: + Notification.objects.filter(user=user, is_read=False).update(is_read=True) + return JsonResponse({'status': 'success', 'message': 'All notifications marked as read'}) + + if notification_id: + Notification.objects.filter(id=notification_id, user=user).update(is_read=True) + return JsonResponse({'status': 'success', 'message': 'Notification marked as read'}) + + return JsonResponse({'error': 'Provide notification_id or mark_all=true'}, status=400) + except Exception as e: + log("error", "NotificationMarkReadView error", request=request, logger_data={"error": str(e)}) + return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500) + + +@method_decorator(csrf_exempt, name='dispatch') +class NotificationCountView(View): + def post(self, request): + try: + user, token, data, error_response = validate_token_and_get_user(request, error_status_code=True) + if error_response: + return error_response + + count = Notification.objects.filter(user=user, is_read=False).count() + return JsonResponse({'status': 'success', 'unread_count': count}) + except Exception as e: + log("error", "NotificationCountView error", request=request, logger_data={"error": str(e)}) + return JsonResponse({'error': 'An unexpected server error occurred.'}, status=500) diff --git a/requirements.txt b/requirements.txt index be31299..c088acc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ Django>=4.2 Pillow django-summernote +google-auth>=2.0.0