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…
	
	Add table
		
		Reference in a new issue