diff --git a/src/website/webview/migrations/0001_initial.py b/src/website/webview/migrations/0001_initial.py new file mode 100644 index 00000000..441786b4 --- /dev/null +++ b/src/website/webview/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2025-04-30 14:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('shared', '0047_alter_cvederivationclusterproposal_status_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Profile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('subscriptions', models.ManyToManyField(related_name='subscribers', to='shared.nixpkgsissue')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/src/website/webview/models.py b/src/website/webview/models.py index 6b202199..248936ce 100644 --- a/src/website/webview/models.py +++ b/src/website/webview/models.py @@ -1 +1,14 @@ # Create your models here. +from django.contrib.auth.models import User +from django.db import models +from shared.models import NixpkgsIssue + + +class Profile(models.Model): + """ + Profile associated to a user, storing extra non-auth-related data such as + active issue subscriptions. + """ + + user = models.OneToOneField(User, on_delete=models.CASCADE) + subscriptions = models.ManyToManyField(NixpkgsIssue, related_name="subscribers") diff --git a/src/website/webview/templates/issue_detail.html b/src/website/webview/templates/issue_detail.html index 704b5981..645cbc98 100644 --- a/src/website/webview/templates/issue_detail.html +++ b/src/website/webview/templates/issue_detail.html @@ -1,4 +1,5 @@ {% extends "base.html" %} +{% load viewutils %} {% block title %} {{ issue }} @@ -6,6 +7,26 @@ {% block content %} +
+
+ + {% csrf_token %} + + + {% if user|is_subscribed_to:object %} + + {% elif user.is_authenticated %} + + {% endif %} +
+
+

{{ object.code }}

{{ object.description.value }}

diff --git a/src/website/webview/templatetags/viewutils.py b/src/website/webview/templatetags/viewutils.py index 484d820c..b82cbf8b 100644 --- a/src/website/webview/templatetags/viewutils.py +++ b/src/website/webview/templatetags/viewutils.py @@ -1,16 +1,20 @@ import datetime import json +from logging import getLogger from typing import Any, TypedDict, cast from django import template from django.template.context import Context from shared.auth import isadmin, ismaintainer from shared.listeners.cache_suggestions import parse_drv_name +from shared.models import NixpkgsIssue from shared.models.cve import AffectedProduct from shared.models.linkage import ( CVEDerivationClusterProposal, ) +logger = getLogger(__name__) + register = template.Library() @@ -123,6 +127,18 @@ def is_maintainer_or_admin(user: Any) -> bool: return is_maintainer(user) or is_admin(user) +@register.filter +def is_subscribed_to(user: Any, issue: NixpkgsIssue) -> bool: + if user is None or user.is_anonymous: + return False + else: + profile = user.profile + if profile is None: + return False + else: + return profile.subscriptions.filter(id=issue.id).exists() + + @register.inclusion_tag("components/suggestion.html", takes_context=True) def suggestion( context: Context, diff --git a/src/website/webview/views.py b/src/website/webview/views.py index d3d79091..8fc6fa0d 100644 --- a/src/website/webview/views.py +++ b/src/website/webview/views.py @@ -7,11 +7,14 @@ from django.core.validators import RegexValidator from django.db import transaction +from django.shortcuts import render from django.urls import reverse from shared.github import create_gh_issue from shared.logs import SuggestionActivityLog from shared.models.cached import CachedSuggestions +from webview.models import Profile + if typing.TYPE_CHECKING: # prevent typecheck from failing on some historic type # https://stackoverflow.com/questions/60271481/django-mypy-valuesqueryset-type-hint @@ -426,6 +429,39 @@ def get_cves_for_derivation(self, drv: Any) -> QuerySet | None: existing_cves = Container.objects.filter(cve__cve_id__in=cves) return existing_cves or None + def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + if not request.user: + return HttpResponseForbidden() + + user = request.user + nixpkgs_issue_id = request.POST.get("nixpkgs_issue_id") + subscribe = request.POST.get("subscribe") + nixpkgs_issue = get_object_or_404(NixpkgsIssue, id=nixpkgs_issue_id) + + if subscribe == "subscribe": + profile, _ = Profile.objects.get_or_create(user=user) + profile.subscriptions.add(nixpkgs_issue_id) + profile.save() + elif subscribe == "unsubscribe": + try: + profile = Profile.objects.get(user=user) + profile.subscriptions.remove(nixpkgs_issue) + profile.save() + except Profile.DoesNotExist: + # This can't really happen from the interface since the + # Unsubscribe button is only visible when the user is subscribed + # to said issue. We log it but we don't bother showing the user + # an error message. + logger.error( + f"Tried to unsubscribe user {user.id} from issue #{nixpkgs_issue_id} but user doesn't have a profile" + ) + else: + logger.warn( + f"Ignoring subscription action with unexpected `subscribe` value: {subscribe}" + ) + + return render(request, "issue_detail.html", {"object": nixpkgs_issue}) + class NixpkgsIssueListView(ListView): template_name = "issue_list.html"