diff --git a/.github/workflows/build-and-deploy.yaml b/.github/workflows/build-and-deploy.yaml index 399ccccb..820486d3 100644 --- a/.github/workflows/build-and-deploy.yaml +++ b/.github/workflows/build-and-deploy.yaml @@ -45,13 +45,12 @@ jobs: cache-to: type=local,dest=/tmp/.buildx-cache tags: pennlabs/penn-mobile-backend:latest,pennlabs/penn-mobile-backend:${{ github.sha }} outputs: type=docker,dest=/tmp/image.tar - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: name: build-backend path: /tmp/image.tar needs: backend-check - build-frontend: name: Build frontend runs-on: ubuntu-latest @@ -74,7 +73,7 @@ jobs: cache-to: type=local,dest=/tmp/.buildx-cache tags: pennlabs/penn-mobile-frontend:latest,pennlabs/penn-mobile-frontend:${{ github.sha }} outputs: type=docker,dest=/tmp/image.tar - - uses: actions/upload-artifact@v2 + - uses: actions/upload-artifact@v4 with: name: build-frontend path: /tmp/image.tar @@ -86,8 +85,8 @@ jobs: if: github.ref == 'refs/heads/master' steps: - uses: actions/checkout@v2 - - uses: actions/download-artifact@v2 - - uses: geekyeggo/delete-artifact@v1 + - uses: actions/download-artifact@v4 + - uses: geekyeggo/delete-artifact@v5 with: name: |- build-backend diff --git a/README.md b/README.md index 2845020d..8ed08f02 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,10 @@ Final Steps: - `pipenv run python manage.py migrate` - `pipenv run python manage.py runserver 8000` -Setting up precommit: +Making git blame [correct](https://github.com/pennlabs/penn-mobile/pull/287) (_optional_): +- `git config blame.ignoreRevsFile .git-blame-ignore-revs` + +Setting up precommit (_optional_): - `pipenv run pre-commit install` ## Creating Users @@ -47,4 +50,4 @@ In separate terminal windows, run the following commands: ## Exploring the API -- Expore the API via our [auto-generated documentation](https://pennmobile.org/api/documentation/)! This is a really good way to click around and discover stuff. \ No newline at end of file +- Expore the API via our [auto-generated documentation](https://pennmobile.org/api/documentation/)! This is a really good way to click around and discover stuff. diff --git a/backend/gsr_booking/admin.py b/backend/gsr_booking/admin.py index e9210d54..93deb3b2 100644 --- a/backend/gsr_booking/admin.py +++ b/backend/gsr_booking/admin.py @@ -3,6 +3,29 @@ from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking, Reservation +class GroupMembershipInline(admin.TabularInline): + model = GroupMembership + extra = 0 + + readonly_fields = ["name"] + + def name(self, obj): + return obj.user.get_full_name() + + def get_fields(self, request, obj=None): + fields = super().get_fields(request, obj) + to_remove = ["user", "name"] + return ["name"] + [f for f in fields if f not in to_remove] + + +class GroupAdmin(admin.ModelAdmin): + search_fields = ["name__icontains"] + list_display = ["name"] + ordering = ["name"] + + inlines = [GroupMembershipInline] + + class GroupMembershipAdmin(admin.ModelAdmin): search_fields = ["user__username__icontains", "group__name__icontains"] @@ -16,7 +39,7 @@ def get_queryset(self, request): ordering = ["-in_use"] -admin.site.register(Group) +admin.site.register(Group, GroupAdmin) admin.site.register(GroupMembership, GroupMembershipAdmin) admin.site.register(GSR, GSRAdmin) admin.site.register(GSRBooking) diff --git a/backend/gsr_booking/serializers.py b/backend/gsr_booking/serializers.py index 1c94873e..76bf5cca 100644 --- a/backend/gsr_booking/serializers.py +++ b/backend/gsr_booking/serializers.py @@ -20,21 +20,13 @@ def get_is_wharton(self, obj): return obj["lid"] == 1 -class MiniUserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ["username", "first_name", "last_name"] - - class GroupMembershipSerializer(serializers.ModelSerializer): - user = MiniUserSerializer(read_only=True) group = serializers.SlugRelatedField(slug_field="name", queryset=Group.objects.all()) color = serializers.SlugRelatedField(slug_field="color", read_only=True, source="group") class Meta: model = GroupMembership fields = [ - "user", "group", "type", "pennkey_allow", @@ -73,29 +65,6 @@ def to_internal_value(self, data): return None # TODO: If you want to update based on BookingField, implement this. -class UserSerializer(serializers.ModelSerializer): - booking_groups = serializers.SerializerMethodField() - - def get_booking_groups(self, obj): - result = [] - for membership in GroupMembership.objects.filter(accepted=True, user=obj): - result.append( - { - "name": membership.group.name, - "id": membership.group.id, - "color": membership.group.color, - "pennkey_allow": membership.pennkey_allow, - "notifications": membership.notifications, - } - ) - - return result - - class Meta: - model = User - fields = ["username", "booking_groups"] - - class GSRSerializer(serializers.ModelSerializer): class Meta: model = GSR diff --git a/backend/gsr_booking/urls.py b/backend/gsr_booking/urls.py index 7f6e2b01..a54a1e12 100644 --- a/backend/gsr_booking/urls.py +++ b/backend/gsr_booking/urls.py @@ -10,16 +10,16 @@ GroupMembershipViewSet, GroupViewSet, Locations, + MyMembershipViewSet, RecentGSRs, ReservationsView, - UserViewSet, ) from utils.cache import Cache router = routers.DefaultRouter() -router.register(r"users", UserViewSet) +router.register(r"mymemberships", MyMembershipViewSet, "mymemberships") router.register(r"membership", GroupMembershipViewSet) router.register(r"groups", GroupViewSet) diff --git a/backend/gsr_booking/views.py b/backend/gsr_booking/views.py index 9bf6aa3f..e4b9eac5 100644 --- a/backend/gsr_booking/views.py +++ b/backend/gsr_booking/views.py @@ -11,66 +11,31 @@ from gsr_booking.api_wrapper import APIError, GSRBooker, WhartonGSRBooker from gsr_booking.models import GSR, Group, GroupMembership, GSRBooking -from gsr_booking.serializers import ( - GroupMembershipSerializer, - GroupSerializer, - GSRSerializer, - UserSerializer, -) +from gsr_booking.serializers import GroupMembershipSerializer, GroupSerializer, GSRSerializer from pennmobile.analytics import Metric, record_analytics User = get_user_model() -class UserViewSet(viewsets.ReadOnlyModelViewSet): - """ - Can specify `me` instead of the `username` to retrieve details on the current user. - """ - - queryset = User.objects.all().prefetch_related( - Prefetch("booking_groups", Group.objects.filter(memberships__accepted=True)) - ) +class MyMembershipViewSet(viewsets.ReadOnlyModelViewSet): permission_classes = [IsAuthenticated] - serializer_class = UserSerializer - lookup_field = "username" - filter_backends = [DjangoFilterBackend] - filterset_fields = ["username", "first_name", "last_name"] - - def get_object(self): - lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field - param = self.kwargs[lookup_url_kwarg] - if param == "me": - return self.request.user - else: - return super().get_object() + serializer_class = GroupMembershipSerializer def get_queryset(self): - if not self.request.user.is_authenticated: - return User.objects.none() + return GroupMembership.objects.filter(user=self.request.user, accepted=True) - queryset = User.objects.all() - queryset = queryset.prefetch_related( - Prefetch( - "memberships", - GroupMembership.objects.filter( - group__in=self.request.user.booking_groups.all(), accepted=True - ), - ) - ) - return queryset - - @action(detail=True, methods=["get"]) - def invites(self, request, username=None): + @action(detail=False, methods=["get"]) + def invites(self, request): """ Retrieve all invites for a given user. """ - - user = get_object_or_404(User, username=username) return Response( GroupMembershipSerializer( GroupMembership.objects.filter( - user=user, accepted=False, group__in=self.request.user.booking_groups.all() + user=request.user, + accepted=False, + group__in=self.request.user.booking_groups.all(), ), many=True, ).data diff --git a/backend/pennmobile/settings/base.py b/backend/pennmobile/settings/base.py index fbdd07ef..71900cef 100644 --- a/backend/pennmobile/settings/base.py +++ b/backend/pennmobile/settings/base.py @@ -186,3 +186,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/management/commands/load_target_populations.py b/backend/portal/management/commands/load_target_populations.py index 5169256b..82ecf247 100644 --- a/backend/portal/management/commands/load_target_populations.py +++ b/backend/portal/management/commands/load_target_populations.py @@ -43,7 +43,7 @@ def get_years(self, years): # creates new class year in August in preparation for upcoming school year if years is None: return ( - [+x for x in range(4)] + [timezone.localtime().year + x for x in range(4)] if timezone.localtime().month < 8 else [timezone.localtime().year + x for x in range(1, 5)] ) 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..7f58e493 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..c94ab187 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 self.initial_data.get("target_populations", "") != "" + 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/gsr_booking/test_gsr_booking.py b/backend/tests/gsr_booking/test_gsr_booking.py index 1559dbbd..7965e35f 100644 --- a/backend/tests/gsr_booking/test_gsr_booking.py +++ b/backend/tests/gsr_booking/test_gsr_booking.py @@ -8,7 +8,7 @@ User = get_user_model() -class UserViewTestCase(TestCase): +class MyMembershipViewTestCase(TestCase): def setUp(self): self.user1 = User.objects.create_user( username="user1", password="password", first_name="user", last_name="one" @@ -17,28 +17,25 @@ def setUp(self): username="user2", password="password", first_name="user", last_name="two" ) - self.group = Group.objects.create(owner=self.user1, name="g1", color="blue") - self.group.members.add(self.user1) - memship = self.group.memberships.all()[0] - memship.accepted = True - memship.save() + Group.objects.create( + owner=self.user1, name="g1", color="blue" + ) # creating group also adds user + group2 = Group.objects.create(owner=self.user2, name="g2", color="blue") + GroupMembership.objects.create(user=self.user1, group=group2, accepted=True) + group3 = Group.objects.create(owner=self.user2, name="g3", color="blue") + GroupMembership.objects.create(user=self.user1, group=group3) self.client = APIClient() self.client.login(username="user1", password="password") - def test_user_list(self): - response = self.client.get("/gsr/users/") + def test_user_memberships(self): + response = self.client.get("/gsr/mymemberships/") self.assertTrue(200, response.status_code) self.assertEqual(2, len(response.data)) - def test_user_detail_in_group(self): - response = self.client.get("/gsr/users/user1/") - self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data["booking_groups"])) - - def test_me_user_detail_in_group(self): - response = self.client.get("/gsr/users/me/") + def test_user_invites(self): + response = self.client.get("/gsr/mymemberships/invites/") self.assertTrue(200, response.status_code) - self.assertEqual(2, len(response.data["booking_groups"])) + self.assertEqual(1, len(response.data)) class MembershipViewTestCase(TestCase): @@ -159,7 +156,7 @@ def setUp(self): def test_get_groups(self): response = self.client.get("/gsr/groups/") self.assertEqual(200, response.status_code) - self.assertEqual(2, len(response.data)) + self.assertEqual(1, len(response.data)) def test_get_groups_includes_invites(self): GroupMembership.objects.create(user=self.user1, group=self.group2, accepted=False) @@ -173,7 +170,7 @@ def test_get_group_not_involved_fails(self): def test_make_group(self): response = self.client.post("/gsr/groups/", {"name": "gx", "color": "blue"}) self.assertEqual(201, response.status_code, response.data) - self.assertEqual(5, Group.objects.count()) + self.assertEqual(3, Group.objects.count()) self.assertEqual("user1", Group.objects.get(name="gx").owner.username) def test_only_accepted_memberships(self): 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/user/test_notifs.py b/backend/tests/user/test_notifs.py index 512b271a..cbeb8075 100644 --- a/backend/tests/user/test_notifs.py +++ b/backend/tests/user/test_notifs.py @@ -9,7 +9,7 @@ from identity.identity import attest, container, get_platform_jwks from rest_framework.test import APIClient -from gsr_booking.models import GSR, Group, GSRBooking, Reservation +from gsr_booking.models import GSR, GSRBooking, Reservation from user.models import NotificationSetting, NotificationToken @@ -350,7 +350,6 @@ def setUp(self): start=g.start, end=g.end, creator=self.test_user, - group=Group.objects.get(owner=self.test_user), ) g.reservation = r 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/user/models.py b/backend/user/models.py index deb3714b..dc71b5c3 100644 --- a/backend/user/models.py +++ b/backend/user/models.py @@ -3,7 +3,6 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from gsr_booking.models import Group from laundry.models import LaundryRoom from penndata.models import FitnessRoom @@ -71,7 +70,6 @@ def create_or_update_user_profile(sender, instance, created, **kwargs): object exists for that User, it will create one """ Profile.objects.get_or_create(user=instance) - Group.objects.get_or_create(owner=instance, name="Me", color="#14f7d1") # notifications token, _ = NotificationToken.objects.get_or_create(user=instance) 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/dashboard/DashboardHeader.tsx b/frontend/components/dashboard/DashboardHeader.tsx index 5d5b21cb..791c076f 100644 --- a/frontend/components/dashboard/DashboardHeader.tsx +++ b/frontend/components/dashboard/DashboardHeader.tsx @@ -5,7 +5,6 @@ import 'react-loading-skeleton/dist/skeleton.css' import { Group, Row } from '@/components/styles/Layout' import { Subtitle } from '@/components/styles/Text' -import { colors } from '@/components/styles/colors' import { PageType } from '@/utils/types' import { Button, PostPollToggle } from '@/components/styles/Buttons' import { CREATE_POLL_ROUTE, CREATE_POST_ROUTE } from '@/utils/routes' @@ -42,11 +41,15 @@ export const DashboardHeader = ({ : CREATE_POLL_ROUTE } > - - - + + diff --git a/frontend/components/dashboard/EmptyDashboard.tsx b/frontend/components/dashboard/EmptyDashboard.tsx index a952437c..149f1c15 100644 --- a/frontend/components/dashboard/EmptyDashboard.tsx +++ b/frontend/components/dashboard/EmptyDashboard.tsx @@ -27,10 +27,10 @@ const EmptyDashboard = ({ page }: { page: PageType }) => { Looks like you're new here.

- Penn Mobile Portal allows organizations to connect and engage with - students on the Penn Mobile app. Make posts for recruiting, events, - or campaigns and watch in real time as users see and interact with - your content. + Penn Portal allows organizations to connect and engage with students + on the Penn Mobile app. Make posts for recruiting, events, or + campaigns and watch in real time as users see and interact with your + content. Ready to get started? 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()) } }) } diff --git a/frontend/components/form/StatusBar.tsx b/frontend/components/form/StatusBar.tsx index 3949534b..5b9da0f1 100644 --- a/frontend/components/form/StatusBar.tsx +++ b/frontend/components/form/StatusBar.tsx @@ -43,7 +43,7 @@ const StatusBar = ({ status }: iStatusBarProps) => { return ( <> - + diff --git a/frontend/components/header/Header.tsx b/frontend/components/header/Header.tsx index bb65b893..f5952170 100644 --- a/frontend/components/header/Header.tsx +++ b/frontend/components/header/Header.tsx @@ -4,7 +4,7 @@ import Head from 'next/head' const Header = () => ( <> - Penn Mobile Portal + Portal ` - border-width: 0; - background-color: ${(props) => - props.active ? colors.MEDIUM_BLUE : colors.LIGHTER_GRAY}; - color: ${(props) => props.active && 'white'}; - border-radius: 100px; - padding: 0.25rem 1rem; - outline: none; +export const ToggleOption = React.forwardRef< + HTMLButtonElement, + { active: boolean } & React.ButtonHTMLAttributes +>(({ active, className, ...props }, ref) => ( +