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.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']
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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 .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})
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue