@@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import DigestOutbox, ReminderOutbox
|
||||
|
||||
|
||||
@admin.register(DigestOutbox)
|
||||
class DigestOutboxAdmin(admin.ModelAdmin):
|
||||
list_display = ("profile", "scheduled_for", "recipient", "status", "sent_at")
|
||||
list_filter = ("status", "profile")
|
||||
search_fields = ("recipient", "subject", "dedupe_key")
|
||||
readonly_fields = ("payload", "dedupe_key", "created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(ReminderOutbox)
|
||||
class ReminderOutboxAdmin(admin.ModelAdmin):
|
||||
list_display = ("profile", "reminder_type", "scheduled_for", "recipient", "status", "sent_at")
|
||||
list_filter = ("status", "reminder_type", "profile")
|
||||
search_fields = ("recipient", "subject", "dedupe_key", "payload")
|
||||
readonly_fields = ("payload", "dedupe_key", "created_at", "updated_at")
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.notifications"
|
||||
verbose_name = "Meldingen"
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.2.16 on 2026-07-20 23:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('profiles', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DigestOutbox',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('dedupe_key', models.CharField(max_length=200, unique=True)),
|
||||
('scheduled_for', models.DateTimeField(db_index=True)),
|
||||
('recipient', models.EmailField(blank=True, max_length=254)),
|
||||
('subject', models.CharField(max_length=300)),
|
||||
('payload', models.JSONField(default=dict)),
|
||||
('status', models.CharField(choices=[('pending', 'Klaar'), ('sent', 'Verzonden'), ('failed', 'Mislukt'), ('skipped', 'Overgeslagen')], default='pending', max_length=16)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('error_message', models.CharField(blank=True, max_length=1000)),
|
||||
('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='digests', to='profiles.searchprofile')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-scheduled_for'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,93 @@
|
||||
# Generated by Django 5.2.16 on 2026-07-21 00:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("notifications", "0001_initial"),
|
||||
("jobs", "0001_initial"),
|
||||
("profiles", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ReminderOutbox",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
("reminder_type", models.CharField(choices=[
|
||||
("closing", "Sluitingsherinnering"),
|
||||
("follow_up", "Opvolgherinnering"),
|
||||
("top_match", "Topmatch"),
|
||||
], max_length=16)),
|
||||
("dedupe_key", models.CharField(max_length=255, unique=True)),
|
||||
("scheduled_for", models.DateTimeField(db_index=True)),
|
||||
("recipient", models.EmailField(blank=True, max_length=254)),
|
||||
("subject", models.CharField(max_length=300)),
|
||||
("payload", models.JSONField(default=dict)),
|
||||
(
|
||||
"status",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pending", "Klaar"),
|
||||
("sent", "Verzonden"),
|
||||
("failed", "Mislukt"),
|
||||
("skipped", "Overgeslagen"),
|
||||
],
|
||||
default="pending",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("sent_at", models.DateTimeField(blank=True, null=True)),
|
||||
("error_message", models.CharField(blank=True, max_length=1000)),
|
||||
(
|
||||
"application",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reminders",
|
||||
to="jobs.application",
|
||||
),
|
||||
),
|
||||
(
|
||||
"job",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reminders",
|
||||
to="jobs.jobposting",
|
||||
),
|
||||
),
|
||||
(
|
||||
"profile",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="reminders",
|
||||
to="profiles.searchprofile",
|
||||
),
|
||||
),
|
||||
(
|
||||
"score_run",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="reminders",
|
||||
to="jobs.scorerun",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={"ordering": ["-scheduled_for"]},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.models import TimeStampedModel
|
||||
|
||||
|
||||
class DigestOutbox(TimeStampedModel):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Klaar"
|
||||
SENT = "sent", "Verzonden"
|
||||
FAILED = "failed", "Mislukt"
|
||||
SKIPPED = "skipped", "Overgeslagen"
|
||||
|
||||
profile = models.ForeignKey(
|
||||
"profiles.SearchProfile", on_delete=models.CASCADE, related_name="digests"
|
||||
)
|
||||
dedupe_key = models.CharField(max_length=200, unique=True)
|
||||
scheduled_for = models.DateTimeField(db_index=True)
|
||||
recipient = models.EmailField(blank=True)
|
||||
subject = models.CharField(max_length=300)
|
||||
payload = models.JSONField(default=dict)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
error_message = models.CharField(max_length=1000, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-scheduled_for"]
|
||||
|
||||
def mark_sent(self) -> None:
|
||||
self.status = self.Status.SENT
|
||||
self.sent_at = timezone.now()
|
||||
self.error_message = ""
|
||||
self.save(update_fields=["status", "sent_at", "error_message", "updated_at"])
|
||||
|
||||
def mark_failed(self, message: str) -> None:
|
||||
self.status = self.Status.FAILED
|
||||
self.error_message = message[:1000]
|
||||
self.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
def mark_skipped(self, reason: str) -> None:
|
||||
self.status = self.Status.SKIPPED
|
||||
self.error_message = reason[:1000]
|
||||
self.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
|
||||
class ReminderOutbox(TimeStampedModel):
|
||||
class ReminderType(models.TextChoices):
|
||||
CLOSING = "closing", "Sluitingsherinnering"
|
||||
FOLLOW_UP = "follow_up", "Opvolgherinnering"
|
||||
TOP_MATCH = "top_match", "Topmatch"
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "Klaar"
|
||||
SENT = "sent", "Verzonden"
|
||||
FAILED = "failed", "Mislukt"
|
||||
SKIPPED = "skipped", "Overgeslagen"
|
||||
|
||||
profile = models.ForeignKey(
|
||||
"profiles.SearchProfile", on_delete=models.CASCADE, related_name="reminders"
|
||||
)
|
||||
reminder_type = models.CharField(max_length=16, choices=ReminderType.choices)
|
||||
dedupe_key = models.CharField(max_length=255, unique=True)
|
||||
scheduled_for = models.DateTimeField(db_index=True)
|
||||
recipient = models.EmailField(blank=True)
|
||||
subject = models.CharField(max_length=300)
|
||||
payload = models.JSONField(default=dict)
|
||||
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
|
||||
job = models.ForeignKey(
|
||||
"jobs.JobPosting", on_delete=models.SET_NULL, null=True, blank=True, related_name="reminders"
|
||||
)
|
||||
application = models.ForeignKey(
|
||||
"jobs.Application", on_delete=models.SET_NULL, null=True, blank=True, related_name="reminders"
|
||||
)
|
||||
score_run = models.ForeignKey(
|
||||
"jobs.ScoreRun", on_delete=models.SET_NULL, null=True, blank=True, related_name="reminders"
|
||||
)
|
||||
sent_at = models.DateTimeField(null=True, blank=True)
|
||||
error_message = models.CharField(max_length=1000, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-scheduled_for"]
|
||||
|
||||
def mark_sent(self) -> None:
|
||||
self.status = self.Status.SENT
|
||||
self.sent_at = timezone.now()
|
||||
self.error_message = ""
|
||||
self.save(update_fields=["status", "sent_at", "error_message", "updated_at"])
|
||||
|
||||
def mark_failed(self, message: str) -> None:
|
||||
self.status = self.Status.FAILED
|
||||
self.error_message = message[:1000]
|
||||
self.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
def mark_skipped(self, reason: str) -> None:
|
||||
self.status = self.Status.SKIPPED
|
||||
self.error_message = reason[:1000]
|
||||
self.save(update_fields=["status", "error_message", "updated_at"])
|
||||
@@ -0,0 +1,443 @@
|
||||
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()
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from celery import shared_task
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.profiles.models import SearchProfile
|
||||
|
||||
from .models import DigestOutbox, ReminderOutbox
|
||||
from .services import (
|
||||
create_daily_outbox,
|
||||
create_due_reminders,
|
||||
send_digest,
|
||||
send_reminder,
|
||||
)
|
||||
|
||||
|
||||
@shared_task(name="apps.notifications.tasks.build_due_digests")
|
||||
def build_due_digests() -> dict[str, int]:
|
||||
created = sent = 0
|
||||
for profile in SearchProfile.objects.filter(is_active=True).select_related("user"):
|
||||
outbox = create_daily_outbox(profile)
|
||||
if outbox is None:
|
||||
continue
|
||||
created += 1
|
||||
if outbox.status == DigestOutbox.Status.PENDING:
|
||||
send_digest(outbox)
|
||||
sent += 1
|
||||
return {"processed": created, "sent": sent}
|
||||
|
||||
|
||||
@shared_task(name="apps.notifications.tasks.build_due_reminders")
|
||||
def build_due_reminders() -> dict[str, int]:
|
||||
now = timezone.now()
|
||||
created = sent = 0
|
||||
for profile in SearchProfile.objects.filter(is_active=True).select_related("user"):
|
||||
outboxes = create_due_reminders(profile, now=now)
|
||||
created += len(outboxes)
|
||||
for outbox in ReminderOutbox.objects.filter(
|
||||
status=ReminderOutbox.Status.PENDING, scheduled_for__lte=now
|
||||
).select_related("profile"):
|
||||
send_reminder(outbox)
|
||||
sent += 1
|
||||
return {"created": created, "sent": sent}
|
||||
Reference in New Issue
Block a user