from __future__ import annotations from datetime import UTC, date, datetime, time, timedelta from decimal import Decimal from typing import Any from zoneinfo import ZoneInfo from django.conf import settings from django.core.mail import EmailMultiAlternatives from django.template.loader import render_to_string from django.utils import timezone from apps.jobs.models import Application, Feedback, JobPosting, ScoreRun from apps.profiles.models import SearchProfile from .models import DigestOutbox, ReminderOutbox TOP_MATCH_MIN_CONFIDENCE = Decimal("0.85") def _to_local(profile: SearchProfile, *, now: datetime | None = None) -> datetime: now_utc = now or timezone.now() tz = ZoneInfo(profile.timezone or "Europe/Brussels") return now_utc.astimezone(tz) def _hidden_job_ids(profile: SearchProfile) -> set[str]: return set( str(job_id) for job_id in Feedback.objects.filter( user=profile.user, action=Feedback.Action.HIDE ).values_list("job_id", flat=True) ) def _applied_job_ids(profile: SearchProfile) -> set[str]: return set( str(job_id) for job_id in Application.objects.filter( user=profile.user, status=Application.Status.APPLIED ).values_list("job_id", flat=True) ) def _recipient(profile: SearchProfile) -> str: return settings.DIGEST_RECIPIENT or profile.user.email def _is_quiet_hours(profile: SearchProfile, now_local: datetime) -> bool: quiet_start = profile.quiet_hours_start quiet_end = profile.quiet_hours_end if quiet_start == quiet_end: return False current = now_local.timetz().replace(tzinfo=None) if quiet_start < quiet_end: return quiet_start <= current < quiet_end return current >= quiet_start or current < quiet_end def _scheduled_for(profile: SearchProfile, now_local: datetime) -> datetime: if not _is_quiet_hours(profile, now_local): return now_local quiet_end = profile.quiet_hours_end candidate = datetime.combine(now_local.date(), quiet_end, tzinfo=now_local.tzinfo) if now_local >= candidate: candidate = datetime.combine( now_local.date() + timedelta(days=1), quiet_end, tzinfo=now_local.tzinfo, ) return candidate def _ensure_follow_up_weekday(follow_up_date: date, weekdays: list[int]) -> date: if not weekdays: return follow_up_date allowed = set(int(day) for day in weekdays if isinstance(day, int)) for extra_days in range(0, 14): candidate = follow_up_date + timedelta(days=extra_days) if candidate.weekday() in allowed: return candidate return follow_up_date def _build_reminder_outbox( profile: SearchProfile, reminder_type: ReminderOutbox.ReminderType, *, dedupe_key: str, scheduled_for: datetime, recipient: str, subject: str, payload: dict[str, Any], job: JobPosting | None = None, application: Application | None = None, score_run: ScoreRun | None = None, ) -> ReminderOutbox: outbox, _ = ReminderOutbox.objects.get_or_create( dedupe_key=dedupe_key, defaults={ "profile": profile, "reminder_type": reminder_type, "scheduled_for": scheduled_for, "recipient": recipient, "subject": subject, "payload": payload, "status": ReminderOutbox.Status.PENDING if recipient else ReminderOutbox.Status.SKIPPED, "error_message": "Geen reminderontvanger ingesteld" if not recipient else "", "job": job, "application": application, "score_run": score_run, }, ) if outbox.status == ReminderOutbox.Status.SKIPPED and recipient: outbox.reminder_type = reminder_type outbox.scheduled_for = scheduled_for outbox.recipient = recipient outbox.subject = subject outbox.payload = payload outbox.job = job outbox.application = application outbox.score_run = score_run outbox.status = ReminderOutbox.Status.PENDING outbox.error_message = "" outbox.save( update_fields=[ "reminder_type", "scheduled_for", "recipient", "subject", "payload", "job", "application", "score_run", "status", "error_message", "updated_at", ] ) return outbox def _build_digest_payload(profile: SearchProfile, *, local_date=None) -> dict[str, Any]: tz = ZoneInfo(profile.timezone or "Europe/Brussels") now_local = timezone.now().astimezone(tz) local_date = local_date or now_local.date() since_local = datetime.combine(local_date - timedelta(days=1), profile.digest_time, tzinfo=tz) scores = _latest_recommendations(profile, since=since_local.astimezone(UTC)) strong = [score for score in scores if score.recommendation == ScoreRun.Recommendation.STRONG] possible = [ score for score in scores if score.recommendation == ScoreRun.Recommendation.POSSIBLE ] return { "date": local_date.isoformat(), "profile": profile.name, "strong_count": len(strong), "possible_count": len(possible), "scores": [ { "job_id": str(score.job_id), "title": score.job.original_title, "employer": score.job.employer_name, "location": score.job.raw_location, "score": float(score.score), "confidence": float(score.confidence), "recommendation": score.recommendation, "positives": score.positives[:3], "concerns": score.concerns[:2], "url": score.job.canonical_url, } for score in scores ], } def build_digest_payload(profile: SearchProfile, *, local_date=None) -> dict[str, Any]: return _build_digest_payload(profile, local_date=local_date) def _latest_recommendations(profile: SearchProfile, *, since: datetime) -> list[ScoreRun]: hidden_ids = _hidden_job_ids(profile) scores = ( ScoreRun.objects.select_related("job", "job__employer") .filter( profile=profile, created_at__gte=since, job__status=JobPosting.Status.ACTIVE, recommendation__in=[ScoreRun.Recommendation.STRONG, ScoreRun.Recommendation.POSSIBLE], ) .exclude(job_id__in=hidden_ids) .order_by("-score", "-created_at") ) result: list[ScoreRun] = [] seen: set[str] = set() for score in scores[:300]: key = str(score.job_id) if key in seen: continue seen.add(key) result.append(score) if len(result) >= 20: break return result def create_daily_outbox(profile: SearchProfile, *, now=None) -> DigestOutbox | None: now = now or timezone.now() tz = ZoneInfo(profile.timezone or "Europe/Brussels") local_now = now.astimezone(tz) scheduled_local = datetime.combine(local_now.date(), profile.digest_time, tzinfo=tz) if local_now < scheduled_local or local_now > scheduled_local + timedelta(minutes=30): return None dedupe_key = f"daily:{profile.pk}:{local_now.date().isoformat()}" recipient = settings.DIGEST_RECIPIENT or profile.user.email payload = _build_digest_payload(profile, local_date=local_now.date()) outbox, _ = DigestOutbox.objects.get_or_create( dedupe_key=dedupe_key, defaults={ "profile": profile, "scheduled_for": scheduled_local.astimezone(UTC), "recipient": recipient, "subject": ( f"VacatureRadar — {payload['strong_count']} sterke en " f"{payload['possible_count']} mogelijke matches" ), "payload": payload, "status": DigestOutbox.Status.PENDING if recipient else DigestOutbox.Status.SKIPPED, "error_message": "Geen digestontvanger ingesteld" if not recipient else "", }, ) return outbox def create_closing_reminders(profile: SearchProfile, *, now=None) -> list[ReminderOutbox]: if not profile.reminders_enabled: return [] now_local = _to_local(profile, now=now) due_start = datetime.combine(now_local.date(), time.min, tzinfo=now_local.tzinfo).astimezone( UTC ) due_end = due_start + timedelta(days=1) hidden_ids = _hidden_job_ids(profile) applied_ids = _applied_job_ids(profile) scheduled_for = _scheduled_for(profile, now_local) recipient = _recipient(profile) outboxes: list[ReminderOutbox] = [] for job in ( JobPosting.objects.select_related("employer") .filter( status=JobPosting.Status.ACTIVE, valid_through__gte=due_start, valid_through__lt=due_end, ) .exclude(id__in=hidden_ids) ): if str(job.pk) in applied_ids: continue local_valid_through = ( job.valid_through.astimezone(now_local.tzinfo) if job.valid_through else now_local ) dedupe_key = f"closing:{profile.pk}:{job.pk}:{local_valid_through.date().isoformat()}" outboxes.append( _build_reminder_outbox( profile, ReminderOutbox.ReminderType.CLOSING, dedupe_key=dedupe_key, scheduled_for=scheduled_for, recipient=recipient, subject=f"Vacature verloopt deze week: {job.original_title}", payload={ "reminder_type": ReminderOutbox.ReminderType.CLOSING, "job_id": str(job.pk), "job_title": job.original_title, "employer": job.employer_name, "link": job.canonical_url, "status": job.get_status_display(), "valid_through": ( local_valid_through.isoformat() if job.valid_through else None ), }, job=job, ) ) return outboxes def create_follow_up_reminders(profile: SearchProfile, *, now=None) -> list[ReminderOutbox]: if not profile.reminders_enabled: return [] now_local = _to_local(profile, now=now) today = now_local.date() recipient = _recipient(profile) scheduled_for = _scheduled_for(profile, now_local) outboxes: list[ReminderOutbox] = [] applications = ( Application.objects.select_related("job") .filter(user=profile.user, follow_up_date__isnull=False) .exclude(status__in=[Application.Status.REJECTED, Application.Status.WITHDRAWN]) ) hidden_ids = _hidden_job_ids(profile) for application in applications: if str(application.job_id) in hidden_ids: continue follow_date = _ensure_follow_up_weekday( application.follow_up_date, list(profile.follow_up_weekdays or []), ) if follow_date > today: continue dedupe_key = f"follow-up:{profile.pk}:{application.pk}:{follow_date.isoformat()}" outboxes.append( _build_reminder_outbox( profile, ReminderOutbox.ReminderType.FOLLOW_UP, dedupe_key=dedupe_key, scheduled_for=scheduled_for, recipient=recipient, subject=f"Opvolgereminder: {application.job.original_title}", payload={ "reminder_type": ReminderOutbox.ReminderType.FOLLOW_UP, "application_id": str(application.pk), "job_id": str(application.job.pk), "job_title": application.job.original_title, "job_status": application.get_status_display(), "employer": application.job.employer_name, "link": application.job.canonical_url, "follow_up_date": ( application.follow_up_date.isoformat() if application.follow_up_date else None ), }, job=application.job, application=application, ) ) return outboxes def create_top_match_reminders(profile: SearchProfile, *, now=None) -> list[ReminderOutbox]: if not profile.reminders_enabled or not profile.top_match_reminders_enabled: return [] now = now or timezone.now() now_local = _to_local(profile, now=now) since = now_local - timedelta(hours=18) hidden_ids = _hidden_job_ids(profile) applied_ids = _applied_job_ids(profile) recipient = _recipient(profile) scheduled_for = _scheduled_for(profile, now_local) outboxes: list[ReminderOutbox] = [] for score in ( ScoreRun.objects.select_related("job", "job__employer") .filter( profile=profile, recommendation=ScoreRun.Recommendation.STRONG, score__gte=profile.top_match_threshold, confidence__gte=TOP_MATCH_MIN_CONFIDENCE, created_at__gte=since.astimezone(UTC), ) .exclude(job_id__in=hidden_ids) .order_by("-created_at") ): if str(score.job_id) in applied_ids: continue if score.job.status != JobPosting.Status.ACTIVE: continue dedupe_key = f"topmatch:{profile.pk}:{score.job.pk}:{score.pk}" outboxes.append( _build_reminder_outbox( profile, ReminderOutbox.ReminderType.TOP_MATCH, dedupe_key=dedupe_key, scheduled_for=scheduled_for, recipient=recipient, subject=f"Topmatch gevonden: {score.job.original_title}", payload={ "reminder_type": ReminderOutbox.ReminderType.TOP_MATCH, "score_id": str(score.pk), "job_id": str(score.job.pk), "job_title": score.job.original_title, "employer": score.job.employer_name, "link": score.job.canonical_url, "score": float(score.score), "confidence": float(score.confidence), "threshold": profile.top_match_threshold, "detected_at": timezone.localtime(score.created_at).isoformat(), }, job=score.job, score_run=score, ) ) return outboxes def create_due_reminders(profile: SearchProfile, *, now=None) -> list[ReminderOutbox]: outboxes: list[ReminderOutbox] = [] outboxes.extend(create_closing_reminders(profile, now=now)) outboxes.extend(create_follow_up_reminders(profile, now=now)) outboxes.extend(create_top_match_reminders(profile, now=now)) return outboxes def send_digest(outbox: DigestOutbox) -> None: if outbox.status != DigestOutbox.Status.PENDING: return context = {"digest": outbox.payload} text_body = render_to_string("emails/digest.txt", context) html_body = render_to_string("emails/digest.html", context) message = EmailMultiAlternatives( subject=outbox.subject, body=text_body, from_email=settings.DEFAULT_FROM_EMAIL, to=[outbox.recipient], ) message.attach_alternative(html_body, "text/html") try: message.send(fail_silently=False) except Exception as exc: outbox.status = DigestOutbox.Status.FAILED outbox.error_message = f"{exc.__class__.__name__}: {exc}"[:1000] outbox.save(update_fields=["status", "error_message", "updated_at"]) raise outbox.mark_sent() def send_reminder(outbox: ReminderOutbox) -> None: if outbox.status != ReminderOutbox.Status.PENDING: return context = {"reminder": outbox.payload, "reminder_type": outbox.reminder_type} text_body = render_to_string("emails/reminder.txt", context) html_body = render_to_string("emails/reminder.html", context) message = EmailMultiAlternatives( subject=outbox.subject, body=text_body, from_email=settings.DEFAULT_FROM_EMAIL, to=[outbox.recipient], ) message.attach_alternative(html_body, "text/html") try: message.send(fail_silently=False) except Exception as exc: outbox.status = ReminderOutbox.Status.FAILED outbox.error_message = f"{exc.__class__.__name__}: {exc}"[:1000] outbox.save(update_fields=["status", "error_message", "updated_at"]) raise outbox.mark_sent()