diff --git a/conservancy/usethesource/emails.py b/conservancy/usethesource/emails.py new file mode 100644 index 0000000000000000000000000000000000000000..8fde9d6f261dfae3fe48bca30c86f2550ea385ed --- /dev/null +++ b/conservancy/usethesource/emails.py @@ -0,0 +1,15 @@ +from django.core.mail import EmailMessage + + +def make_comment_email(comment): + subject = f'Re: {comment.candidate.name}' + signature = comment.user.get_full_name() or comment.user.username + sender = f'{signature} ' + to = ['nutbush@lists.sfconservancy.org'] + body = f'{comment.message}\n\n--\n{signature}' + headers = {'Message-ID': comment.email_message_id} + if in_reply_to := comment.in_reply_to(): + # From my testing, both "In-Reply-To" and "References" headers trigger + # email threading in Thunderbind. Sticking to "In-Reply-To" for now. + headers['In-Reply-To'] = in_reply_to + return EmailMessage(subject, body, sender, to, headers=headers) diff --git a/conservancy/usethesource/migrations/0003_comment_email_message_id.py b/conservancy/usethesource/migrations/0003_comment_email_message_id.py new file mode 100644 index 0000000000000000000000000000000000000000..19d5e7116eeac6a99fd729680a4bc4c0a721adbb --- /dev/null +++ b/conservancy/usethesource/migrations/0003_comment_email_message_id.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.19 on 2024-01-25 20:59 + +import conservancy.usethesource.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('usethesource', '0002_auto_20231030_1830'), + ] + + operations = [ + migrations.AddField( + model_name='comment', + name='email_message_id', + field=models.CharField(default=conservancy.usethesource.models.gen_message_id, max_length=255), + ), + ] diff --git a/conservancy/usethesource/migrations/0004_auto_20240125_2352.py b/conservancy/usethesource/migrations/0004_auto_20240125_2352.py new file mode 100644 index 0000000000000000000000000000000000000000..575f39afd8af6d7455fdc754faa166c9fc3dc35d --- /dev/null +++ b/conservancy/usethesource/migrations/0004_auto_20240125_2352.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.19 on 2024-01-25 23:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('usethesource', '0003_comment_email_message_id'), + ] + + operations = [ + migrations.AlterField( + model_name='candidate', + name='description', + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name='candidate', + name='release_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/conservancy/usethesource/models.py b/conservancy/usethesource/models.py index 08d0969956727fdbecd72aba253727eb4d980a1c..84afcf204a42e08127ef57e80f3ff937230fe7f9 100644 --- a/conservancy/usethesource/models.py +++ b/conservancy/usethesource/models.py @@ -1,14 +1,18 @@ +import uuid + from django.contrib.auth.models import User from django.db import models class Candidate(models.Model): + """A source/binary release we'd like to verify CCS status of.""" + name = models.CharField('Candidate name', max_length=50) slug = models.SlugField(max_length=50, unique=True) vendor = models.CharField('Vendor name', max_length=50) device = models.CharField('Device name', max_length=50) - release_date = models.DateField() - description = models.TextField() + release_date = models.DateField(null=True, blank=True) + description = models.TextField(blank=True) source_url = models.URLField() binary_url = models.URLField(blank=True) ordering = models.SmallIntegerField(default=0) @@ -20,14 +24,38 @@ class Candidate(models.Model): return self.name +def gen_message_id(): + """Generate a time-based identifier for use in "In-Reply-To" header.""" + return f'<{uuid.uuid1()}@sfconservancy.org>' + + class Comment(models.Model): + """A comment about experiences or learnings building the candidate.""" + candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE) user = models.ForeignKey(User, on_delete=models.PROTECT) time = models.DateTimeField(auto_now_add=True) message = models.TextField() + email_message_id = models.CharField(max_length=255, default=gen_message_id) def __str__(self): - return f'{self.candidate.name}, {self.user}, {self.time}' + return f'{self.id}: {self.candidate.name}, {self.user}, {self.time}' + + def _find_previous_comment(self): + try: + return self.__class__.objects.filter(candidate=self.candidate, id__lt=self.id).latest('id') + except self.__class__.DoesNotExist: + return None + + def in_reply_to(self): + """Determine the message_id of the previous comment. + + Used for email threading. + """ + if prev_comment := self._find_previous_comment(): + return prev_comment.email_message_id + else: + return None class Meta: ordering = ['id'] diff --git a/conservancy/usethesource/templates/usethesource/comment_form.html b/conservancy/usethesource/templates/usethesource/comment_form.html index cfda8b4dd1445a8086e0ace0aea3fcf1adf8de22..7c62ee332c9275c58ba955ed8f5dec26ce5d4f0e 100644 --- a/conservancy/usethesource/templates/usethesource/comment_form.html +++ b/conservancy/usethesource/templates/usethesource/comment_form.html @@ -1,4 +1,4 @@ -
+ {% csrf_token %} {{ form.message }}
diff --git a/conservancy/usethesource/templates/usethesource/edit_comment_form.html b/conservancy/usethesource/templates/usethesource/edit_comment_form.html index e03998bc4fbc14a632143989e7fbd507717c6f6a..0bab29c445d163db241d24b9c78df8d14caa778a 100644 --- a/conservancy/usethesource/templates/usethesource/edit_comment_form.html +++ b/conservancy/usethesource/templates/usethesource/edit_comment_form.html @@ -1,4 +1,4 @@ - + {% csrf_token %} {{ form.message }}
diff --git a/conservancy/usethesource/templates/usethesource/save_button_partial.html b/conservancy/usethesource/templates/usethesource/save_button_partial.html index 8ae5cdff41f759e32886cdc096465553f2a9c262..5cc66948bab29498f9413f69f78c86e29321b82f 100644 --- a/conservancy/usethesource/templates/usethesource/save_button_partial.html +++ b/conservancy/usethesource/templates/usethesource/save_button_partial.html @@ -1 +1 @@ - + diff --git a/conservancy/usethesource/tests.py b/conservancy/usethesource/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..e13d83edbcd33405b1f85cc09f53b58912c49494 --- /dev/null +++ b/conservancy/usethesource/tests.py @@ -0,0 +1,56 @@ +import datetime +import re + +from django.contrib.auth.models import User +import pytest + +from . import models +from .emails import make_comment_email +from .models import Candidate, Comment + + +def make_candidate(save=False, **kwargs): + defaults = { + 'name': 'Test Candidate', + 'slug': 'test', + 'vendor': 'test vendor', + 'device': 'test device', + 'release_date': datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc) + } + merged = defaults | kwargs + obj = Candidate(**merged) + if save: + obj.save() + return obj + + +def test_message_id(): + assert re.match(r'<.+@.+>', models.gen_message_id()) + + +@pytest.mark.django_db +def test_comment_knows_comment_its_replying_to(): + user = User.objects.create() + candidate = make_candidate(name='Test Candidate', save=True) + first_comment = Comment.objects.create(user=user, candidate=candidate) + second_comment = Comment.objects.create(user=user, candidate=candidate) + assert second_comment._find_previous_comment() == first_comment + assert second_comment.in_reply_to() == first_comment.email_message_id + + +@pytest.mark.django_db +def test_comment_email(): + user = User.objects.create(first_name='Test', last_name='User') + candidate = make_candidate(name='Test Candidate', save=True) + models.Comment.objects.create(candidate=candidate, user=user) + second_comment = models.Comment.objects.create( + candidate=candidate, + user=user, + message='Test message', + ) + email = make_comment_email(second_comment) + assert 'Message-ID' in email.extra_headers + assert 'In-Reply-To' in email.extra_headers + assert email.subject == 'Re: Test Candidate' + assert 'Test message' in email.body + assert 'Test User' in email.body diff --git a/conservancy/usethesource/views.py b/conservancy/usethesource/views.py index d2acba9d7f8bb422a43f2827f43f62fdb633b786..34bf684fc51502ba1a0e43d51a2d5fc1aeaebac1 100644 --- a/conservancy/usethesource/views.py +++ b/conservancy/usethesource/views.py @@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404, redirect, render from .models import Candidate, Comment from .forms import CommentForm, DownloadForm +from .emails import make_comment_email def landing_page(request): @@ -38,11 +39,13 @@ def create_comment(request, slug): else: form = CommentForm(request.POST) if form.is_valid(): - instance = form.save(commit=False) - instance.candidate = candidate - instance.user = request.user - instance.save() - return redirect('usethesource:view_comment', comment_id=instance.id, show_add='true') + comment = form.save(commit=False) + comment.candidate = candidate + comment.user = request.user + comment.save() + email = make_comment_email(comment) + email.send() + return redirect('usethesource:view_comment', comment_id=comment.id, show_add='true') return render(request, 'usethesource/comment_form.html', {'form': form, 'candidate': candidate}) @@ -54,9 +57,8 @@ def edit_comment(request, comment_id): else: form = CommentForm(request.POST, instance=comment) if form.is_valid(): - instance = form.save(commit=False) - instance.save() - return redirect('usethesource:view_comment', comment_id=instance.id, show_add='false') + comment = form.save() + return redirect('usethesource:view_comment', comment_id=comment.id, show_add='false') return render(request, 'usethesource/edit_comment_form.html', {'form': form, 'comment': comment})