diff --git a/backend/pennmobile/settings/base.py b/backend/pennmobile/settings/base.py
index c54b3e21..cf285a89 100644
--- a/backend/pennmobile/settings/base.py
+++ b/backend/pennmobile/settings/base.py
@@ -180,3 +180,11 @@
AWS_QUERYSTRING_AUTH = False
AWS_S3_FILE_OVERWRITE = False
AWS_DEFAULT_ACL = "public-read"
+
+EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
+EMAIL_HOST = os.environ.get("SMTP_HOST", "")
+EMAIL_USE_TLS = True
+EMAIL_PORT = os.environ.get("SMTP_PORT", 587)
+EMAIL_HOST_USER = os.environ.get("SMTP_USERNAME", "")
+EMAIL_HOST_PASSWORD = os.environ.get("SMTP_PASSWORD", "")
+DEFAULT_FROM_EMAIL = os.environ.get("SMTP_FROM_EMAIL", EMAIL_HOST_USER)
diff --git a/backend/pennmobile/templates/email.html b/backend/pennmobile/templates/email.html
new file mode 100644
index 00000000..3a9af953
--- /dev/null
+++ b/backend/pennmobile/templates/email.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Email Notification
+
+
+
+
+
{{ message|linebreaksbr }}
+
+
+
+
Please do not reply to this email. Replies to this email address are not monitored.
+
+
+
+
+
diff --git a/backend/portal/migrations/0016_poll_creator_post_creator.py b/backend/portal/migrations/0016_poll_creator_post_creator.py
new file mode 100644
index 00000000..3143359c
--- /dev/null
+++ b/backend/portal/migrations/0016_poll_creator_post_creator.py
@@ -0,0 +1,36 @@
+# Generated by Django 4.2.9 on 2024-04-17 04:44
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("portal", "0015_auto_20240226_2236"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="poll",
+ name="creator",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AddField(
+ model_name="post",
+ name="creator",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/backend/portal/models.py b/backend/portal/models.py
index d82314b9..b15aeee1 100644
--- a/backend/portal/models.py
+++ b/backend/portal/models.py
@@ -3,6 +3,8 @@
from django.db.models import Q
from django.utils import timezone
+from utils.email import get_backend_manager_emails, send_automated_email
+
User = get_user_model()
@@ -48,17 +50,54 @@ class Content(models.Model):
admin_comment = models.CharField(max_length=255, null=True, blank=True)
target_populations = models.ManyToManyField(TargetPopulation, blank=True)
priority = models.IntegerField(default=0)
+ creator = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True)
class Meta:
abstract = True
+ def _get_email_subject(self):
+ return f"[Portal] {self.__class__._meta.model_name.capitalize()} #{self.id}"
+
+ def _on_create(self):
+ send_automated_email.delay_on_commit(
+ self._get_email_subject(),
+ get_backend_manager_emails(),
+ (
+ f"A new {self.__class__._meta.model_name} for {self.club_code}"
+ f"has been created by {self.creator}."
+ ),
+ )
+
+ def _on_status_change(self):
+ if email := getattr(self.creator, "email", None):
+ send_automated_email.delay_on_commit(
+ self._get_email_subject(),
+ [email],
+ f"Your {self.__class__._meta.model_name} status for {self.club_code} has been "
+ + f"changed to {self.status}."
+ + (
+ f"\n\nAdmin comment: {self.admin_comment}"
+ if self.admin_comment and self.status == self.STATUS_REVISION
+ else ""
+ ),
+ )
+
+ def save(self, *args, **kwargs):
+ prev = self.__class__.objects.filter(id=self.id).first()
+ super().save(*args, **kwargs)
+ if prev is None:
+ self._on_create()
+ return
+ if self.status != prev.status:
+ self._on_status_change()
+
class Poll(Content):
question = models.CharField(max_length=255)
multiselect = models.BooleanField(default=False)
def __str__(self):
- return f"{self.id} - {self.club_code} - {self.question}"
+ return self.question
class PollOption(models.Model):
@@ -85,4 +124,4 @@ class Post(Content):
image = models.ImageField(upload_to="portal/images", null=True, blank=True)
def __str__(self):
- return f"{self.id} - {self.club_code} - {self.title}"
+ return self.title
diff --git a/backend/portal/serializers.py b/backend/portal/serializers.py
index 51e852ab..fab276dc 100644
--- a/backend/portal/serializers.py
+++ b/backend/portal/serializers.py
@@ -1,7 +1,8 @@
+from django.http.request import QueryDict
from rest_framework import serializers
from portal.logic import check_targets, get_user_clubs, get_user_populations
-from portal.models import Poll, PollOption, PollVote, Post, TargetPopulation
+from portal.models import Content, Poll, PollOption, PollVote, Post, TargetPopulation
class TargetPopulationSerializer(serializers.ModelSerializer):
@@ -10,81 +11,78 @@ class Meta:
fields = "__all__"
-class PollSerializer(serializers.ModelSerializer):
+class ContentSerializer(serializers.ModelSerializer):
class Meta:
- model = Poll
fields = (
"id",
"club_code",
- "question",
"created_date",
"start_date",
"expire_date",
- "multiselect",
"club_comment",
"admin_comment",
"status",
"target_populations",
)
read_only_fields = ("id", "created_date")
+ abstract = True
+
+ def _auto_add_target_population(self, validated_data):
+ # auto add all target populations of a kind if not specified
+ if target_populations := validated_data.get("target_populations"):
+ auto_add_kind = [
+ kind
+ for kind, _ in TargetPopulation.KIND_OPTIONS
+ if not any(population.kind == kind for population in target_populations)
+ ]
+ validated_data["target_populations"] += TargetPopulation.objects.filter(
+ kind__in=auto_add_kind
+ )
+ else:
+ validated_data["target_populations"] = list(TargetPopulation.objects.all())
def create(self, validated_data):
club_code = validated_data["club_code"]
+ user = self.context["request"].user
# ensures user is part of club
- if club_code not in [
- x["club"]["code"] for x in get_user_clubs(self.context["request"].user)
- ]:
+ if not any([x["club"]["code"] == club_code for x in get_user_clubs(user)]):
raise serializers.ValidationError(
- detail={"detail": "You do not access to create a Poll under this club."}
+ detail={
+ "detail": "You do not have access to create a "
+ + f"{self.Meta.model._meta.model_name.capitalize()} under this club."
+ }
)
+
# ensuring user cannot create an admin comment upon creation
validated_data["admin_comment"] = None
- validated_data["status"] = Poll.STATUS_DRAFT
-
- # TODO: toggle this off when multiselect functionality is available
- validated_data["multiselect"] = False
-
- year = False
- major = False
- school = False
- degree = False
-
- for population in validated_data["target_populations"]:
- if population.kind == TargetPopulation.KIND_YEAR:
- year = True
- elif population.kind == TargetPopulation.KIND_MAJOR:
- major = True
- elif population.kind == TargetPopulation.KIND_SCHOOL:
- school = True
- elif population.kind == TargetPopulation.KIND_DEGREE:
- degree = True
-
- if not year:
- validated_data["target_populations"] += list(
- TargetPopulation.objects.filter(kind=TargetPopulation.KIND_YEAR)
- )
- if not major:
- validated_data["target_populations"] += list(
- TargetPopulation.objects.filter(kind=TargetPopulation.KIND_MAJOR)
- )
- if not school:
- validated_data["target_populations"] += list(
- TargetPopulation.objects.filter(kind=TargetPopulation.KIND_SCHOOL)
- )
- if not degree:
- validated_data["target_populations"] += list(
- TargetPopulation.objects.filter(kind=TargetPopulation.KIND_DEGREE)
- )
+ validated_data["status"] = Content.STATUS_DRAFT
+
+ self._auto_add_target_population(validated_data)
+
+ validated_data["creator"] = user
return super().create(validated_data)
def update(self, instance, validated_data):
- # if Poll is updated, then approve should be false
+ # if Content is updated, then approve should be false
if not self.context["request"].user.is_superuser:
- validated_data["status"] = Poll.STATUS_DRAFT
+ validated_data["status"] = Content.STATUS_DRAFT
+
+ self._auto_add_target_population(validated_data)
+
return super().update(instance, validated_data)
+class PollSerializer(ContentSerializer):
+ class Meta(ContentSerializer.Meta):
+ model = Poll
+ fields = (
+ *ContentSerializer.Meta.fields,
+ "question",
+ "multiselect",
+ )
+
+
class PollOptionSerializer(serializers.ModelSerializer):
class Meta:
model = PollOption
@@ -204,7 +202,7 @@ class Meta:
)
-class PostSerializer(serializers.ModelSerializer):
+class PostSerializer(ContentSerializer):
image = serializers.ImageField(write_only=True, required=False, allow_null=True)
image_url = serializers.SerializerMethodField("get_image_url")
@@ -223,106 +221,25 @@ def get_image_url(self, obj):
else:
return image.url
- class Meta:
+ class Meta(ContentSerializer.Meta):
model = Post
fields = (
- "id",
- "club_code",
+ *ContentSerializer.Meta.fields,
"title",
"subtitle",
"post_url",
"image",
"image_url",
- "created_date",
- "start_date",
- "expire_date",
- "club_comment",
- "admin_comment",
- "status",
- "target_populations",
)
- read_only_fields = ("id", "created_date", "target_populations")
- def parse_target_populations(self, raw_target_populations):
- if isinstance(raw_target_populations, list):
- ids = raw_target_populations
- else:
- ids = (
- list()
- if len(raw_target_populations) == 0
- else [int(id) for id in raw_target_populations.split(",")]
+ def is_valid(self, *args, **kwargs):
+ if isinstance(self.initial_data, QueryDict):
+ self.initial_data = self.initial_data.dict()
+ self.initial_data["target_populations"] = list(
+ (
+ map(int, self.initial_data["target_populations"].split(","))
+ if "target_populations" in self.initial_data
+ else []
+ ),
)
- return TargetPopulation.objects.filter(id__in=ids)
-
- def update_target_populations(self, target_populations):
- year = False
- major = False
- school = False
- degree = False
-
- for population in target_populations:
- if population.kind == TargetPopulation.KIND_YEAR:
- year = True
- elif population.kind == TargetPopulation.KIND_MAJOR:
- major = True
- elif population.kind == TargetPopulation.KIND_SCHOOL:
- school = True
- elif population.kind == TargetPopulation.KIND_DEGREE:
- degree = True
-
- if not year:
- target_populations |= TargetPopulation.objects.filter(kind=TargetPopulation.KIND_YEAR)
- if not major:
- target_populations |= TargetPopulation.objects.filter(kind=TargetPopulation.KIND_MAJOR)
- if not school:
- target_populations |= TargetPopulation.objects.filter(kind=TargetPopulation.KIND_SCHOOL)
- if not degree:
- target_populations |= TargetPopulation.objects.filter(kind=TargetPopulation.KIND_DEGREE)
-
- return target_populations
-
- def create(self, validated_data):
- club_code = validated_data["club_code"]
- # Ensures user is part of club
- if club_code not in [
- x["club"]["code"] for x in get_user_clubs(self.context["request"].user)
- ]:
- raise serializers.ValidationError(
- detail={"detail": "You do not access to create a Poll under this club."}
- )
-
- # Ensuring user cannot create an admin comment upon creation
- validated_data["admin_comment"] = None
- validated_data["status"] = Post.STATUS_DRAFT
-
- instance = super().create(validated_data)
-
- # Update target populations
- # If none of a categories were selected, then we will auto-select
- # all populations in that categary
- data = self.context["request"].data
- raw_target_populations = self.parse_target_populations(data["target_populations"])
- target_populations = self.update_target_populations(raw_target_populations)
-
- instance.target_populations.set(target_populations)
- instance.save()
-
- return instance
-
- def update(self, instance, validated_data):
- # if post is updated, then approved should be false
- if not self.context["request"].user.is_superuser:
- validated_data["status"] = Post.STATUS_DRAFT
-
- data = self.context["request"].data
-
- # Additional logic for target populations
- if "target_populations" in data:
- target_populations = self.parse_target_populations(data["target_populations"])
- data = self.context["request"].data
- raw_target_populations = self.parse_target_populations(data["target_populations"])
- target_populations = self.update_target_populations(raw_target_populations)
-
- validated_data["target_populations"] = target_populations
-
- return super().update(instance, validated_data)
+ return super().is_valid(*args, **kwargs)
diff --git a/backend/tests/portal/test_polls.py b/backend/tests/portal/test_polls.py
index 2762d755..1002d803 100644
--- a/backend/tests/portal/test_polls.py
+++ b/backend/tests/portal/test_polls.py
@@ -9,6 +9,7 @@
from rest_framework.test import APIClient
from portal.models import Poll, PollOption, PollVote, TargetPopulation
+from utils.email import get_backend_manager_emails
User = get_user_model()
@@ -229,6 +230,44 @@ def test_option_vote_view(self):
# test that options key is in response
self.assertIn("options", res_json)
+ @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs)
+ @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs)
+ @mock.patch("utils.email.send_automated_email.delay_on_commit")
+ def test_send_email_on_create(self, mock_send_email):
+ payload = {
+ "club_code": "pennlabs",
+ "question": "How is this question? 2",
+ "expire_date": timezone.localtime() + datetime.timedelta(days=1),
+ "admin_comment": "asdfs 2",
+ "target_populations": [],
+ }
+ self.client.post("/portal/polls/", payload)
+
+ mock_send_email.assert_called_once()
+ self.assertEqual(mock_send_email.call_args[0][1], get_backend_manager_emails())
+
+ @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs)
+ @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs)
+ @mock.patch("utils.email.send_automated_email.delay_on_commit")
+ def test_send_email_on_status_change(self, mock_send_email):
+ payload = {
+ "club_code": "pennlabs",
+ "question": "How is this question? 2",
+ "expire_date": timezone.localtime() + datetime.timedelta(days=1),
+ "admin_comment": "asdfs 2",
+ "target_populations": [],
+ }
+ self.client.force_authenticate(user=self.test_user)
+ self.client.post("/portal/polls/", payload)
+ mock_send_email.assert_called_once()
+
+ poll = Poll.objects.last()
+ poll.status = Poll.STATUS_REVISION
+ poll.save()
+
+ self.assertEqual(mock_send_email.call_count, 2)
+ self.assertEqual(mock_send_email.call_args[0][1], [self.test_user.email])
+
class TestPollVotes(TestCase):
"""Tests Create/Update Polls and History"""
diff --git a/backend/tests/portal/test_posts.py b/backend/tests/portal/test_posts.py
index b5624e23..4a2b5bae 100644
--- a/backend/tests/portal/test_posts.py
+++ b/backend/tests/portal/test_posts.py
@@ -9,6 +9,7 @@
from rest_framework.test import APIClient
from portal.models import Post, TargetPopulation
+from utils.email import get_backend_manager_emails
User = get_user_model()
@@ -94,7 +95,9 @@ def test_fail_post(self):
response = self.client.post("/portal/posts/", payload)
res_json = json.loads(response.content)
# should not create post under pennlabs if not aprt of pennlabs
- self.assertEqual("You do not access to create a Poll under this club.", res_json["detail"])
+ self.assertEqual(
+ "You do not have access to create a Post under this club.", res_json["detail"]
+ )
@mock.patch("portal.views.get_user_clubs", mock_get_user_clubs)
@mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs)
@@ -151,3 +154,44 @@ def test_review_post_no_admin_comment(self):
self.assertEqual(1, len(res_json))
self.assertEqual("notpennlabs", res_json[0]["club_code"])
self.assertEqual(2, Post.objects.all().count())
+
+ @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs)
+ @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs)
+ @mock.patch("utils.email.send_automated_email.delay_on_commit")
+ def test_send_email_on_create(self, mock_send_email):
+ payload = {
+ "club_code": "pennlabs",
+ "title": "Test Title 2",
+ "subtitle": "Test Subtitle 2",
+ "target_populations": [self.target_id],
+ "expire_date": timezone.localtime() + datetime.timedelta(days=1),
+ "created_at": timezone.localtime(),
+ "admin_comment": "comment 2",
+ }
+ self.client.post("/portal/posts/", payload)
+ mock_send_email.assert_called_once()
+ self.assertEqual(mock_send_email.call_args[0][1], get_backend_manager_emails())
+
+ @mock.patch("portal.serializers.get_user_clubs", mock_get_user_clubs)
+ @mock.patch("portal.permissions.get_user_clubs", mock_get_user_clubs)
+ @mock.patch("utils.email.send_automated_email.delay_on_commit")
+ def test_send_email_on_status_change(self, mock_send_email):
+ payload = {
+ "club_code": "pennlabs",
+ "title": "Test Title 2",
+ "subtitle": "Test Subtitle 2",
+ "target_populations": [self.target_id],
+ "expire_date": timezone.localtime() + datetime.timedelta(days=1),
+ "created_at": timezone.localtime(),
+ "admin_comment": "comment 2",
+ }
+ self.client.force_authenticate(user=self.test_user)
+ self.client.post("/portal/posts/", payload)
+ mock_send_email.assert_called_once()
+
+ post = Post.objects.last()
+ post.status = Post.STATUS_APPROVED
+ post.save()
+
+ self.assertEqual(mock_send_email.call_count, 2)
+ self.assertEqual(mock_send_email.call_args[0][1], [post.creator.email])
diff --git a/backend/tests/utils/test_email.py b/backend/tests/utils/test_email.py
new file mode 100644
index 00000000..1b033c6c
--- /dev/null
+++ b/backend/tests/utils/test_email.py
@@ -0,0 +1,64 @@
+from unittest import mock
+
+from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Group
+from django.test import TestCase
+
+from utils.email import get_backend_manager_emails, send_automated_email, send_mail
+
+
+User = get_user_model()
+
+
+class EmailTestCase(TestCase):
+ def setUp(self):
+ self.group = Group.objects.create(name="backend_managers")
+ self.user1 = User.objects.create_user(
+ username="user1", password="password", email="user1@domain.com"
+ )
+ self.user2 = User.objects.create_user(
+ username="user2", password="password", email="user2@domain.com"
+ )
+ self.user3 = User.objects.create_user(username="user3", password="password")
+
+ self.group.user_set.add(self.user1)
+ self.group.user_set.add(self.user3)
+
+ @mock.patch("utils.email.django_send_mail")
+ def test_send_mail(self, mock_send_mail):
+ send_mail("testing321", ["test@example.com"], message="test message?!")
+ mock_send_mail.assert_called_once_with(
+ subject="testing321",
+ message="test message?!",
+ from_email=None,
+ recipient_list=["test@example.com"],
+ fail_silently=False,
+ html_message=None,
+ )
+
+ def test_send_mail_error(self):
+ with self.assertRaises(ValueError):
+ send_mail("testing321", None, message="test message?!")
+
+ @mock.patch("utils.email.django_send_mail")
+ def test_send_automated_email(self, mock_send_mail):
+ send_automated_email("testing123", ["test@example.com"], "test message?!")
+ html_message = mock_send_mail.call_args[1]["html_message"]
+ mock_send_mail.assert_called_once_with(
+ subject="testing123",
+ message=None,
+ from_email=None,
+ recipient_list=["test@example.com"],
+ fail_silently=False,
+ html_message=html_message,
+ )
+ self.assertIsNotNone(html_message)
+ self.assertIn("test message?!", html_message)
+
+ def test_get_backend_manager_emails(self):
+ emails = get_backend_manager_emails()
+ self.assertEqual(emails, ["user1@domain.com"])
+
+ self.group.delete()
+ emails = get_backend_manager_emails()
+ self.assertEqual(emails, [])
diff --git a/backend/utils/email.py b/backend/utils/email.py
new file mode 100644
index 00000000..c8608cc6
--- /dev/null
+++ b/backend/utils/email.py
@@ -0,0 +1,37 @@
+from celery import shared_task
+from django.contrib.auth.models import Group
+from django.core.mail import send_mail as django_send_mail
+from django.template.loader import get_template
+
+
+@shared_task(name="utils.send_mail")
+def send_mail(subject, recipient_list, message=None, html_message=None):
+ if recipient_list is None:
+ raise ValueError("Recipient list cannot be None")
+ success = django_send_mail(
+ subject=subject,
+ message=message,
+ from_email=None,
+ recipient_list=recipient_list,
+ fail_silently=False,
+ html_message=html_message,
+ )
+ return success
+ # TODO: log upon failure!
+
+
+@shared_task(name="utils.send_automated_email")
+def send_automated_email(subject, recipient_list, message):
+ template = get_template("email.html")
+ html_message = template.render({"message": message})
+ return send_mail(subject, recipient_list, html_message=html_message)
+
+
+def get_backend_manager_emails():
+ if group := Group.objects.filter(name="backend_managers").first():
+ return list(
+ group.user_set.exclude(email="")
+ .exclude(email__isnull=True)
+ .values_list("email", flat=True)
+ )
+ return []
diff --git a/frontend/components/form/FormHeader.tsx b/frontend/components/form/FormHeader.tsx
index 24dd8db2..fa31009d 100644
--- a/frontend/components/form/FormHeader.tsx
+++ b/frontend/components/form/FormHeader.tsx
@@ -35,13 +35,14 @@ const FormHeader = ({ createMode, state, prevOptionIds }: iFormHeaderProps) => {
const form_data = new FormData()
if (isPost(state)) {
Object.entries(state).forEach(([key, value]) => {
+ if (value === null) return
if (key === 'start_date' || key === 'expire_date') {
const val = (value as Date)?.toISOString()
form_data.append(key, val)
- } else if (key !== 'image') {
- form_data.append(key, value?.toString())
- } else {
+ } else if (key === 'image') {
form_data.append(key, value)
+ } else {
+ form_data.append(key, value?.toString())
}
})
}