Initial deploy setup
deploy / deploy (push) Canceled after 0s

This commit is contained in:
Jens
2026-07-21 14:00:00 +02:00
commit b8091e59bd
285 changed files with 27854 additions and 0 deletions
View File
+19
View File
@@ -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")
+7
View File
@@ -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"]},
),
]
+99
View File
@@ -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"])
+443
View File
@@ -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()
+43
View File
@@ -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}