fix E-Mail notifications
This commit is contained in:
		
							
								
								
									
										44
									
								
								arr_api/management/commands/send_test_email.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								arr_api/management/commands/send_test_email.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,44 @@ | |||||||
|  | from django.core.management.base import BaseCommand | ||||||
|  | from django.contrib.auth import get_user_model | ||||||
|  | from django.utils import timezone | ||||||
|  |  | ||||||
|  | from arr_api.notifications import send_notification_email | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Command(BaseCommand): | ||||||
|  |     help = "Send a test notification email to verify SMTP configuration" | ||||||
|  |  | ||||||
|  |     def add_arguments(self, parser): | ||||||
|  |         parser.add_argument('--to', required=True, help='Recipient email address') | ||||||
|  |         parser.add_argument('--username', default='testuser', help='Username to associate with the email') | ||||||
|  |         parser.add_argument('--type', default='movie', choices=['movie', 'series'], help='Media type for the template') | ||||||
|  |         parser.add_argument('--title', default='Subscribarr Test', help='Title to show in the email') | ||||||
|  |  | ||||||
|  |     def handle(self, *args, **opts): | ||||||
|  |         User = get_user_model() | ||||||
|  |         email = opts['to'] | ||||||
|  |         username = opts['username'] | ||||||
|  |         media_type = opts['type'] | ||||||
|  |         title = opts['title'] | ||||||
|  |  | ||||||
|  |         user, _ = User.objects.get_or_create(username=username, defaults={'email': email}) | ||||||
|  |         if user.email != email: | ||||||
|  |             user.email = email | ||||||
|  |             user.save(update_fields=['email']) | ||||||
|  |  | ||||||
|  |         # Use current time as air_date for nicer formatting | ||||||
|  |         send_notification_email( | ||||||
|  |             user=user, | ||||||
|  |             media_title=title, | ||||||
|  |             media_type=media_type, | ||||||
|  |             overview='This is a test email from Subscribarr to verify your mail settings.', | ||||||
|  |             poster_url=None, | ||||||
|  |             episode_title='Pilot' if media_type == 'series' else None, | ||||||
|  |             season=1 if media_type == 'series' else None, | ||||||
|  |             episode=1 if media_type == 'series' else None, | ||||||
|  |             air_date=timezone.now(), | ||||||
|  |             year=timezone.now().year if media_type == 'movie' else None, | ||||||
|  |             release_type='Test' | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         self.stdout.write(self.style.SUCCESS(f"Test email queued/sent to {email}")) | ||||||
| @@ -7,6 +7,7 @@ from settingspanel.models import AppSettings | |||||||
| import requests | import requests | ||||||
| from dateutil.parser import isoparse | from dateutil.parser import isoparse | ||||||
| import logging | import logging | ||||||
|  | from django.db import transaction | ||||||
|  |  | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
|  |  | ||||||
| @@ -177,6 +178,8 @@ def _dispatch_user_notification(user, subject: str, body_text: str, html_message | |||||||
|             return True |             return True | ||||||
|         # fallback to email |         # fallback to email | ||||||
|     try: |     try: | ||||||
|  |         # Ensure email backend is configured from AppSettings at runtime | ||||||
|  |         _set_runtime_email_settings() | ||||||
|         send_mail( |         send_mail( | ||||||
|             subject=subject, |             subject=subject, | ||||||
|             message=body_text, |             message=body_text, | ||||||
| @@ -326,21 +329,10 @@ def check_and_notify_users(): | |||||||
|             if season is None or number is None: |             if season is None or number is None: | ||||||
|                 continue |                 continue | ||||||
|  |  | ||||||
|             # duplicate guard (per series per day per user) |             # duplicate guard will be handled atomically before dispatch | ||||||
|             if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): |  | ||||||
|                 already_notified = SentNotification.objects.filter( |  | ||||||
|                     media_id=sub.series_id, |  | ||||||
|                     media_type='series', |  | ||||||
|                     air_date=today, |  | ||||||
|                     user=sub.user |  | ||||||
|                 ).exists() |  | ||||||
|                 if already_notified: |  | ||||||
|                     continue |  | ||||||
|  |  | ||||||
|             # check availability via Sonarr hasFile |             # check availability via Sonarr hasFile | ||||||
|             if sonarr_episode_has_file(sub.series_id, season, number): |             if sonarr_episode_has_file(sub.series_id, season, number): | ||||||
|                 if not sub.user.email: |  | ||||||
|                     continue |  | ||||||
|                 # Build subject/body |                 # Build subject/body | ||||||
|                 subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}" |                 subj = f"New episode available: {sub.series_title} S{season:02d}E{number:02d}" | ||||||
|                 body = f"{sub.series_title} S{season:02d}E{number:02d} is now available." |                 body = f"{sub.series_title} S{season:02d}E{number:02d} is now available." | ||||||
| @@ -361,36 +353,48 @@ def check_and_notify_users(): | |||||||
|                     html = render_to_string('arr_api/email/new_media_notification.html', ctx) |                     html = render_to_string('arr_api/email/new_media_notification.html', ctx) | ||||||
|                 except Exception: |                 except Exception: | ||||||
|                     pass |                     pass | ||||||
|  |                 # Reserve duplicate token atomically, then dispatch; rollback on failure | ||||||
|  |                 if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): | ||||||
|  |                     try: | ||||||
|  |                         with transaction.atomic(): | ||||||
|  |                             token, created = SentNotification.objects.get_or_create( | ||||||
|  |                                 user=sub.user, | ||||||
|  |                                 media_id=sub.series_id, | ||||||
|  |                                 media_type='series', | ||||||
|  |                                 air_date=today, | ||||||
|  |                                 defaults={'media_title': sub.series_title} | ||||||
|  |                             ) | ||||||
|  |                         if not created: | ||||||
|  |                             continue | ||||||
|  |                     except Exception: | ||||||
|  |                         # race or DB error -> skip to avoid duplicates | ||||||
|  |                         continue | ||||||
|                 ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html) |                 ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html) | ||||||
|                 # mark as sent unless duplicates are allowed |                 if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): | ||||||
|                 if ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): |                     # allow retry on next run | ||||||
|                     SentNotification.objects.create( |                     try: | ||||||
|                         user=sub.user, |                         SentNotification.objects.filter( | ||||||
|                         media_id=sub.series_id, |                             user=sub.user, | ||||||
|                         media_type='series', |                             media_id=sub.series_id, | ||||||
|                         media_title=sub.series_title, |                             media_type='series', | ||||||
|                         air_date=today |                             air_date=today | ||||||
|                     ) |                         ).delete() | ||||||
|  |                     except Exception: | ||||||
|  |                         pass | ||||||
|  |  | ||||||
|     # Film-Abos |     # Film-Abos | ||||||
|     for sub in MovieSubscription.objects.select_related('user').all(): |     for sub in MovieSubscription.objects.select_related('user').all(): | ||||||
|         it = movie_idx.get(sub.movie_id) |         it = movie_idx.get(sub.movie_id) | ||||||
|  |         # Fallback: if movie_id missing, try match by title | ||||||
|  |         if not it and getattr(sub, 'title', None): | ||||||
|  |             for _mid, _it in movie_idx.items(): | ||||||
|  |                 if (_it.get('title') or '').strip().lower() == (sub.title or '').strip().lower(): | ||||||
|  |                     it = _it | ||||||
|  |                     break | ||||||
|         if not it: |         if not it: | ||||||
|             continue |             continue | ||||||
|  |  | ||||||
|         if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): |  | ||||||
|             already_notified = SentNotification.objects.filter( |  | ||||||
|                 media_id=sub.movie_id, |  | ||||||
|                 media_type='movie', |  | ||||||
|                 air_date=today, |  | ||||||
|                 user=sub.user |  | ||||||
|             ).exists() |  | ||||||
|             if already_notified: |  | ||||||
|                 continue |  | ||||||
|  |  | ||||||
|         if radarr_movie_has_file(sub.movie_id): |         if radarr_movie_has_file(sub.movie_id): | ||||||
|             if not sub.user.email: |  | ||||||
|                 continue |  | ||||||
|             # detect which release matched today |             # detect which release matched today | ||||||
|             rel = None |             rel = None | ||||||
|             try: |             try: | ||||||
| @@ -423,15 +427,32 @@ def check_and_notify_users(): | |||||||
|                 html = render_to_string('arr_api/email/new_media_notification.html', ctx) |                 html = render_to_string('arr_api/email/new_media_notification.html', ctx) | ||||||
|             except Exception: |             except Exception: | ||||||
|                 pass |                 pass | ||||||
|  |             # Reserve duplicate token atomically, then dispatch; rollback on failure | ||||||
|  |             if not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): | ||||||
|  |                 try: | ||||||
|  |                     with transaction.atomic(): | ||||||
|  |                         token, created = SentNotification.objects.get_or_create( | ||||||
|  |                             user=sub.user, | ||||||
|  |                             media_id=sub.movie_id, | ||||||
|  |                             media_type='movie', | ||||||
|  |                             air_date=today, | ||||||
|  |                             defaults={'media_title': sub.title} | ||||||
|  |                         ) | ||||||
|  |                     if not created: | ||||||
|  |                         continue | ||||||
|  |                 except Exception: | ||||||
|  |                     continue | ||||||
|             ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html) |             ok = _dispatch_user_notification(sub.user, subject=subj, body_text=body, html_message=html) | ||||||
|             if ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): |             if not ok and not getattr(settings, 'NOTIFICATIONS_ALLOW_DUPLICATES', False): | ||||||
|                 SentNotification.objects.create( |                 try: | ||||||
|                     user=sub.user, |                     SentNotification.objects.filter( | ||||||
|                     media_id=sub.movie_id, |                         user=sub.user, | ||||||
|                     media_type='movie', |                         media_id=sub.movie_id, | ||||||
|                     media_title=sub.title, |                         media_type='movie', | ||||||
|                     air_date=today |                         air_date=today | ||||||
|                 ) |                     ).delete() | ||||||
|  |                 except Exception: | ||||||
|  |                     pass | ||||||
|  |  | ||||||
|  |  | ||||||
| def has_new_episode_today(series_id): | def has_new_episode_today(series_id): | ||||||
|   | |||||||
| @@ -81,9 +81,13 @@ class ArrIndexView(View): | |||||||
|             eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()] |             eps = [e for e in eps if q in (e.get("seriesTitle") or "").lower()] | ||||||
|             movies = [m for m in movies if q in (m.get("title") or "").lower()] |             movies = [m for m in movies if q in (m.get("title") or "").lower()] | ||||||
|  |  | ||||||
|         # Abonnierte Serien und Filme laden |         # Abonnierte Serien und Filme pro aktuellem Nutzer | ||||||
|         subscribed_series_ids = set(SeriesSubscription.objects.values_list('series_id', flat=True)) |         if request.user.is_authenticated: | ||||||
|         subscribed_movie_ids = set(MovieSubscription.objects.values_list('movie_id', flat=True)) |             subscribed_series_ids = set(SeriesSubscription.objects.filter(user=request.user).values_list('series_id', flat=True)) | ||||||
|  |             subscribed_movie_ids = set(MovieSubscription.objects.filter(user=request.user).values_list('movie_id', flat=True)) | ||||||
|  |         else: | ||||||
|  |             subscribed_series_ids = set() | ||||||
|  |             subscribed_movie_ids = set() | ||||||
|  |  | ||||||
|         # Gruppierung nach Serie |         # Gruppierung nach Serie | ||||||
|         groups = defaultdict(lambda: { |         groups = defaultdict(lambda: { | ||||||
| @@ -197,70 +201,7 @@ class CalendarEventsApi(APIView): | |||||||
|         return Response({"events": events}) |         return Response({"events": events}) | ||||||
|  |  | ||||||
|  |  | ||||||
| class SubscribeSeriesView(View): |  | ||||||
|     @method_decorator(require_POST) |  | ||||||
|     def post(self, request, series_id): |  | ||||||
|         series_data = { |  | ||||||
|             'series_id': series_id, |  | ||||||
|             'series_title': request.POST.get('series_title'), |  | ||||||
|             'series_poster': request.POST.get('series_poster'), |  | ||||||
|             'series_overview': request.POST.get('series_overview'), |  | ||||||
|             'series_genres': request.POST.getlist('series_genres[]', []) |  | ||||||
|         } |  | ||||||
|   |   | ||||||
|         subscription, created = SeriesSubscription.objects.get_or_create( |  | ||||||
|             series_id=series_id, |  | ||||||
|             defaults=series_data |  | ||||||
|         ) |  | ||||||
|          |  | ||||||
|         if created: |  | ||||||
|             messages.success(request, f'Subscribed to series "{series_data["series_title"]}"!') |  | ||||||
|         else: |  | ||||||
|             messages.info(request, f'Series "{series_data["series_title"]}" was already subscribed.') |  | ||||||
|              |  | ||||||
|         return redirect('arr_api:index') |  | ||||||
|  |  | ||||||
| class UnsubscribeSeriesView(View): |  | ||||||
|     @method_decorator(require_POST) |  | ||||||
|     def post(self, request, series_id): |  | ||||||
|         subscription = get_object_or_404(SeriesSubscription, series_id=series_id) |  | ||||||
|         series_title = subscription.series_title |  | ||||||
|         subscription.delete() |  | ||||||
|         messages.success(request, f'Subscription for "{series_title}" has been removed.') |  | ||||||
|         return redirect('arr_api:index') |  | ||||||
|  |  | ||||||
| class SubscribeMovieView(View): |  | ||||||
|     @method_decorator(require_POST) |  | ||||||
|     def post(self, request, movie_id): |  | ||||||
|         movie_data = { |  | ||||||
|             'movie_id': movie_id, |  | ||||||
|             'title': request.POST.get('title'), |  | ||||||
|             'poster': request.POST.get('poster'), |  | ||||||
|             'overview': request.POST.get('overview'), |  | ||||||
|             'genres': request.POST.getlist('genres[]', []), |  | ||||||
|             'release_date': request.POST.get('release_date') |  | ||||||
|         } |  | ||||||
|          |  | ||||||
|         subscription, created = MovieSubscription.objects.get_or_create( |  | ||||||
|             movie_id=movie_id, |  | ||||||
|             defaults=movie_data |  | ||||||
|         ) |  | ||||||
|          |  | ||||||
|         if created: |  | ||||||
|             messages.success(request, f'Subscribed to movie "{movie_data["title"]}"!') |  | ||||||
|         else: |  | ||||||
|             messages.info(request, f'Movie "{movie_data["title"]}" was already subscribed.') |  | ||||||
|              |  | ||||||
|         return redirect('arr_api:index') |  | ||||||
|  |  | ||||||
| class UnsubscribeMovieView(View): |  | ||||||
|     @method_decorator(require_POST) |  | ||||||
|     def post(self, request, movie_id): |  | ||||||
|         subscription = get_object_or_404(MovieSubscription, movie_id=movie_id) |  | ||||||
|         movie_title = subscription.title |  | ||||||
|         subscription.delete() |  | ||||||
|         messages.success(request, f'Subscription for "{movie_title}" has been removed.') |  | ||||||
|         return redirect('arr_api:index') |  | ||||||
|  |  | ||||||
|  |  | ||||||
| @require_POST | @require_POST | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user