Publish curated VacatureRadar source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:19:10 +02:00
commit e29774cb7f
338 files changed
+32699

No files matched your search

View File
Whitespace-only changes.
+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"
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import smtplib
from email.utils import parseaddr
from django.conf import settings
from django.core.mail.backends.base import BaseEmailBackend
from apps.core.services.microsoft365 import (
Microsoft365OAuthError,
acquire_exchange_access_token,
xoauth2_b64,
)
from apps.sources.services.tls import trusted_tls_context
class Microsoft365OAuthEmailBackend(BaseEmailBackend):
"""Send mail through Exchange Online with app-only SASL XOAUTH2."""
def send_messages(self, email_messages) -> int:
if not email_messages:
return 0
sent = 0
try:
token = acquire_exchange_access_token()
username = settings.M365_MAILBOX_USER.strip()
if not username:
raise Microsoft365OAuthError(
"M365_MAILBOX_USER is verplicht voor Microsoft 365-mailuitvoer."
)
with smtplib.SMTP(
settings.EMAIL_HOST or "smtp.office365.com",
settings.EMAIL_PORT,
timeout=settings.M365_SMTP_TIMEOUT_SECONDS,
) as client:
client.ehlo()
client.starttls(context=trusted_tls_context())
client.ehlo()
code, _ = client.docmd("AUTH", f"XOAUTH2 {xoauth2_b64(username, token)}")
if code != 235:
raise Microsoft365OAuthError("Microsoft 365 SMTP OAuth geweigerd.")
for message in email_messages:
recipients = message.recipients()
if not recipients:
continue
sender = parseaddr(message.from_email or settings.DEFAULT_FROM_EMAIL)[1]
client.sendmail(
sender or username,
recipients,
message.message().as_bytes(linesep="\r\n"),
)
sent += 1
except Exception:
if self.fail_silently:
return sent
raise
return sent
@@ -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"]},
),
]
Whitespace-only changes.
+107
View File
@@ -0,0 +1,107 @@
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"])
+446
View File
@@ -0,0 +1,446 @@
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}