from __future__ import annotations from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Count, Prefetch, Q from django.http import HttpRequest from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone from django.views.decorators.cache import never_cache from django.views.decorators.http import require_POST from django.views.generic import ListView from apps.core.rate_limit import clear_rate_limit, is_rate_limited, register_rate_limit_failure from apps.profiles.models import SearchProfile from .forms import MailboxConnectionForm, ManualImportForm from .models import MailboxConnection, Source, SourcePolicyReview, SourceRun from .platforms import PLATFORM_ALERTS from .services.mailbox_connections import MailboxCredentialError, save_mailbox_connection from .services.manual_import import ManualImportError, ManualImportSummary, import_manual_source from .services.policy import create_policy_review from .tasks import fetch_source, poll_mailbox def _source_list_queryset(): return ( Source.objects.prefetch_related( "policy_reviews", Prefetch( "runs", queryset=SourceRun.objects.order_by("-started_at")[:1], to_attr="latest_runs", ), ) .exclude(domain="jobs.example.org", status=Source.Status.DISABLED) .filter( Q(metadata__hidden_from_source_list__isnull=True) | Q(metadata__hidden_from_source_list=False) ) .filter(Q(metadata__watchlist__isnull=True) | Q(metadata__watchlist=False)) .annotate(job_count=Count("job_aliases", distinct=True)) .order_by("name") ) def _watchlist_queryset(): return ( Source.objects.filter(metadata__watchlist=True) .filter( Q(metadata__hidden_from_source_list__isnull=True) | Q(metadata__hidden_from_source_list=False) ) .order_by("name") ) def _manual_import_context( request: HttpRequest, *, form: ManualImportForm, manual_result=None, mailbox_form: MailboxConnectionForm | None = None, editing_mailbox: MailboxConnection | None = None, ): base_queryset = _source_list_queryset() watchlist_queryset = _watchlist_queryset() mailbox_connections = list( request.user.mailbox_connections.filter(platform__in=PLATFORM_ALERTS) ) active_search_profile = SearchProfile.objects.filter(user=request.user, is_active=True).first() return { "sources": base_queryset, "active_source_count": Source.objects.filter(status=Source.Status.ACTIVE).count(), "source_total_count": base_queryset.count(), "review_pending_count": Source.objects.filter(policy=Source.Policy.REVIEW).count(), "failed_source_count": Source.objects.filter( Q(status=Source.Status.QUARANTINED) | Q(failure_count__gt=0) ).count(), "watchlist_sources": watchlist_queryset, "watchlist_count": watchlist_queryset.count(), "manual_import_form": form, "manual_import_bookmarklet_endpoint": request.build_absolute_uri( reverse("sources:manual_import") ), "manual_import_paste_max_bytes": settings.MANUAL_IMPORT_PASTE_MAX_BYTES, "manual_import_result": manual_result, "platform_alert_ingress_ready": any( connection.enabled for connection in mailbox_connections ), "mailbox_connections": mailbox_connections, "platform_alerts": PLATFORM_ALERTS.values(), "mailbox_form": mailbox_form or MailboxConnectionForm(), "editing_mailbox": editing_mailbox, "mailbox_credential_key_ready": bool(settings.MAILBOX_CREDENTIAL_KEYS), "active_search_profile": active_search_profile, } class SourceListView(LoginRequiredMixin, ListView): model = Source template_name = "sources/list.html" context_object_name = "sources" def get_queryset(self): return _source_list_queryset() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context.update( _manual_import_context( self.request, form=ManualImportForm( initial={"source_url": self.request.GET.get("source_url", "")} ), ) ) return context def _bulk_summary(action: str) -> str: mapping = { "allow": "toestaan", "approve_to_trial": "proefrun", "pause": "pauzeren", "dismiss": "afwijzen", } return mapping.get(action, "onbekend") def _action_to_review( action: str, ) -> tuple[SourcePolicyReview.Decision, str, str, str]: if action == "allow": return ( SourcePolicyReview.Decision.ALLOW, Source.Status.ACTIVE, Source.Policy.ALLOW, "Bron actief gemaakt door expliciete goedkeuring", ) if action == "approve_to_trial": return ( SourcePolicyReview.Decision.TRIAL, Source.Status.TRIAL, Source.Policy.REVIEW, "Bron naar proefrun voor technische controle", ) if action == "pause": return ( SourcePolicyReview.Decision.PAUSE, Source.Status.PAUSED, Source.Policy.REVIEW, "Bron tijdelijk gepauzeerd", ) return ( SourcePolicyReview.Decision.DENY, Source.Status.DISABLED, Source.Policy.DENY, "Bron afgewezen", ) @login_required @require_POST def bulk_candidate_action(request): action = request.POST.get("action") source_ids = [int(raw_id) for raw_id in request.POST.getlist("source_ids") if raw_id.isdigit()] sources = _source_list_queryset().filter(id__in=source_ids).exclude(policy=Source.Policy.DENY) if not sources.exists(): messages.info(request, "Geen bruikbare bronregels geselecteerd.") return redirect("sources:list") if action not in {"allow", "approve_to_trial", "pause", "dismiss"}: messages.error(request, "Onbekende actie.") return redirect("sources:list") decision, status, policy, reason = _action_to_review(action) for source in sources: create_policy_review( source, actor=request.user, decision=decision, scope=SourcePolicyReview.Scope.SOURCE, reason=reason, notes="Bulkactie", ) source.status = status source.policy = policy source.policy_reason = reason source.save(update_fields=["status", "policy", "policy_reason", "updated_at"]) messages.success(request, f"{sources.count()} bron(nen) naar {_bulk_summary(action)}.") return redirect("sources:list") @login_required def retry_source(request, pk: int): if request.method == "POST": source = get_object_or_404(Source, pk=pk) if source.policy == Source.Policy.DENY: messages.error(request, "Deze bron is door het bronbeleid geblokkeerd.") else: fetch_source.delay(source.pk, force=True) messages.success(request, "Broncontrole ingepland.") return redirect("sources:list") @login_required @require_POST def save_mailbox(request, pk: int | None = None): instance = None if pk is not None: instance = get_object_or_404(MailboxConnection, pk=pk, user=request.user) form = MailboxConnectionForm(request.POST, instance=instance) if form.is_valid(): try: connection = save_mailbox_connection( user=request.user, cleaned_data=dict(form.cleaned_data), instance=instance, ) except MailboxCredentialError as exc: form.add_error("password", str(exc)) else: poll_mailbox.delay(connection.pk, force=True) messages.success( request, f"{connection.get_platform_display()}-mailbox opgeslagen; " "verbindingstest ingepland.", ) return redirect("sources:list") messages.error(request, "Controleer de mailboxinstellingen.") return render( request, "sources/list.html", _manual_import_context( request, form=ManualImportForm(), mailbox_form=form, editing_mailbox=instance, ), ) @login_required def edit_mailbox(request, pk: int): connection = get_object_or_404(MailboxConnection, pk=pk, user=request.user) if request.method == "POST": return save_mailbox(request, pk=pk) return render( request, "sources/list.html", _manual_import_context( request, form=ManualImportForm(), mailbox_form=MailboxConnectionForm(instance=connection), editing_mailbox=connection, ), ) @login_required @require_POST def sync_platform_alerts(request, pk: int): connection = get_object_or_404(MailboxConnection, pk=pk, user=request.user) if not connection.enabled: messages.error(request, "Activeer deze mailbox voordat je synchroniseert.") else: poll_mailbox.delay(connection.pk, force=True) messages.success(request, f"{connection.get_platform_display()}-mailimport ingepland.") return redirect("sources:list") @login_required @require_POST def toggle_mailbox(request, pk: int): connection = get_object_or_404(MailboxConnection, pk=pk, user=request.user) connection.enabled = not connection.enabled connection.next_poll_at = timezone.now() connection.save(update_fields=["enabled", "next_poll_at", "updated_at"]) state = "geactiveerd" if connection.enabled else "gepauzeerd" messages.success(request, f"{connection.get_platform_display()}-mailbox {state}.") return redirect("sources:list") @login_required @never_cache def manual_import_view(request): rate_limit = is_rate_limited( request, namespace="manual_import", max_attempts=settings.MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS, window_seconds=settings.MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS, block_seconds=settings.MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS, ) if rate_limit.is_blocked: messages.error( request, "Te veel handmatige importverzoeken. Wacht 5 minuten en probeer daarna opnieuw.", ) return render( request, "sources/list.html", _manual_import_context(request, form=ManualImportForm(), manual_result=None), ) if request.method == "POST": form = ManualImportForm(request.POST) result_payload = None if not form.is_valid(): messages.error(request, "Controleer de handmatige importvelden.") return render( request, "sources/list.html", _manual_import_context(request, form=form, manual_result=result_payload), ) cleaned = form.cleaned_data try: summary: ManualImportSummary = import_manual_source( actor=request.user, source_url=cleaned.get("source_url"), pasted_text=cleaned.get("pasted_text"), ) result_payload = summary.to_session_payload() messages.success( request, "Handmatige import uitgevoerd. Bekijk het resultaat hieronder.", ) clear_rate_limit(request, namespace="manual_import") form = ManualImportForm() except ManualImportError as exc: messages.error(request, str(exc)) register_rate_limit_failure( request, namespace="manual_import", max_attempts=settings.MANUAL_IMPORT_RATE_LIMIT_MAX_ATTEMPTS, window_seconds=settings.MANUAL_IMPORT_RATE_LIMIT_WINDOW_SECONDS, block_seconds=settings.MANUAL_IMPORT_RATE_LIMIT_BLOCK_SECONDS, ) return render( request, "sources/list.html", _manual_import_context(request, form=form, manual_result=result_payload), ) return render( request, "sources/list.html", _manual_import_context( request, form=ManualImportForm(initial={"source_url": request.GET.get("source_url", "")}), ), )