Files
VacatureRadar/apps/notifications/services.py
T
Jens a4eced8be5
deploy / deploy (push) Canceled after 0s
Fix release blockers and deployment build
2026-07-21 21:22:29 +02:00

447 lines
16 KiB
Python

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()