447 lines
16 KiB
Python
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()
|