Mirror UTS comments to an email list

This commit is contained in:
Ben Sturmfels 2024-01-26 15:52:38 +11:00
parent 6ceb113ab0
commit cadd69061f
Signed by: bsturmfels
GPG key ID: 023C05E2C9C068F0
9 changed files with 157 additions and 14 deletions

View file

@ -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} <compliance@sfconservancy.org>'
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)

View file

@ -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),
),
]

View file

@ -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),
),
]

View file

@ -1,14 +1,18 @@
import uuid
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
class Candidate(models.Model): class Candidate(models.Model):
"""A source/binary release we'd like to verify CCS status of."""
name = models.CharField('Candidate name', max_length=50) name = models.CharField('Candidate name', max_length=50)
slug = models.SlugField(max_length=50, unique=True) slug = models.SlugField(max_length=50, unique=True)
vendor = models.CharField('Vendor name', max_length=50) vendor = models.CharField('Vendor name', max_length=50)
device = models.CharField('Device name', max_length=50) device = models.CharField('Device name', max_length=50)
release_date = models.DateField() release_date = models.DateField(null=True, blank=True)
description = models.TextField() description = models.TextField(blank=True)
source_url = models.URLField() source_url = models.URLField()
binary_url = models.URLField(blank=True) binary_url = models.URLField(blank=True)
ordering = models.SmallIntegerField(default=0) ordering = models.SmallIntegerField(default=0)
@ -20,14 +24,38 @@ class Candidate(models.Model):
return self.name 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): class Comment(models.Model):
"""A comment about experiences or learnings building the candidate."""
candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE) candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE)
user = models.ForeignKey(User, on_delete=models.PROTECT) user = models.ForeignKey(User, on_delete=models.PROTECT)
time = models.DateTimeField(auto_now_add=True) time = models.DateTimeField(auto_now_add=True)
message = models.TextField() message = models.TextField()
email_message_id = models.CharField(max_length=255, default=gen_message_id)
def __str__(self): 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: class Meta:
ordering = ['id'] ordering = ['id']

View file

@ -1,4 +1,4 @@
<form hx-target="this" hx-swap="outerHTML" hx-post="{% url 'usethesource:add_comment' slug=candidate.slug %}" method="post"> <form hx-target="this" hx-swap="outerHTML" hx-post="{% url 'usethesource:add_comment' slug=candidate.slug %}">
{% csrf_token %} {% csrf_token %}
{{ form.message }} {{ form.message }}
<div class="mt2"> <div class="mt2">

View file

@ -1,4 +1,4 @@
<form class="mb3" hx-target="this" hx-swap="outerHTML" hx-post="{% url 'usethesource:edit_comment' comment_id=comment.id %}" method="post"> <form class="mb3" hx-target="this" hx-swap="outerHTML" hx-post="{% url 'usethesource:edit_comment' comment_id=comment.id %}">
{% csrf_token %} {% csrf_token %}
{{ form.message }} {{ form.message }}
<div class="mt2"> <div class="mt2">

View file

@ -1 +1 @@
<button type="submit" class="b white bg-green pv2 ph3" style="border: none">Save</button> <button type="submit" class="b white bg-green pv2 ph3" style="border: none">Save and email</button>

View file

@ -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

View file

@ -3,6 +3,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from .models import Candidate, Comment from .models import Candidate, Comment
from .forms import CommentForm, DownloadForm from .forms import CommentForm, DownloadForm
from .emails import make_comment_email
def landing_page(request): def landing_page(request):
@ -38,11 +39,13 @@ def create_comment(request, slug):
else: else:
form = CommentForm(request.POST) form = CommentForm(request.POST)
if form.is_valid(): if form.is_valid():
instance = form.save(commit=False) comment = form.save(commit=False)
instance.candidate = candidate comment.candidate = candidate
instance.user = request.user comment.user = request.user
instance.save() comment.save()
return redirect('usethesource:view_comment', comment_id=instance.id, show_add='true') 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}) return render(request, 'usethesource/comment_form.html', {'form': form, 'candidate': candidate})
@ -54,9 +57,8 @@ def edit_comment(request, comment_id):
else: else:
form = CommentForm(request.POST, instance=comment) form = CommentForm(request.POST, instance=comment)
if form.is_valid(): if form.is_valid():
instance = form.save(commit=False) comment = form.save()
instance.save() return redirect('usethesource:view_comment', comment_id=comment.id, show_add='false')
return redirect('usethesource:view_comment', comment_id=instance.id, show_add='false')
return render(request, 'usethesource/edit_comment_form.html', {'form': form, 'comment': comment}) return render(request, 'usethesource/edit_comment_form.html', {'form': form, 'comment': comment})