Mirror UTS comments to an email list
This commit is contained in:
parent
6ceb113ab0
commit
cadd69061f
9 changed files with 157 additions and 14 deletions
15
conservancy/usethesource/emails.py
Normal file
15
conservancy/usethesource/emails.py
Normal 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)
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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']
|
||||
|
|
|
@ -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 %}
|
||||
{{ form.message }}
|
||||
<div class="mt2">
|
||||
|
|
|
@ -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 %}
|
||||
{{ form.message }}
|
||||
<div class="mt2">
|
||||
|
|
|
@ -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>
|
||||
|
|
56
conservancy/usethesource/tests.py
Normal file
56
conservancy/usethesource/tests.py
Normal 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
|
|
@ -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})
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue