Skip to content

Commit

Permalink
fix: add SpamModeration to MemberProfile: when a User is created, a S…
Browse files Browse the repository at this point in the history
…pamModeration object is automatically created for the associated MemberProfile
  • Loading branch information
asuworks committed Nov 28, 2024
1 parent 8b51468 commit bde15a4
Show file tree
Hide file tree
Showing 7 changed files with 127 additions and 42 deletions.
7 changes: 6 additions & 1 deletion django/core/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,14 +262,19 @@ def _validate_content_object(self, instance):
"spam_moderation",
"is_marked_spam",
"get_absolute_url",
"title",
]
for field in required_fields:
if not hasattr(instance, field):
raise ValueError(
f"instance {instance} does not have a {field} attribute"
)

# Ensure either 'title' () or 'username' (for MemberProfile) is present
if not (hasattr(instance, "title") or hasattr(instance, "username")):
raise ValueError(
f"instance {instance} must have either a 'title' or a 'username' attribute"
)

@action(detail=True, methods=["post"], permission_classes=[ModeratorPermissions])
def mark_spam(self, request, **kwargs):
instance = self.get_object()
Expand Down
6 changes: 3 additions & 3 deletions django/core/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import timedelta
from enum import Enum
import logging
import pathlib
from datetime import timedelta
from enum import Enum

from allauth.account.models import EmailAddress
from django import forms
Expand Down Expand Up @@ -355,7 +355,7 @@ def find_users_with_email(self, candidate_email, exclude_user=None):

@add_to_comses_permission_whitelist
@register_snippet
class MemberProfile(index.Indexed, ClusterableModel):
class MemberProfile(index.Indexed, ModeratedContent, ClusterableModel):
"""
Contains additional comses.net information, possibly linked to a CoMSES Member / site account
"""
Expand Down
45 changes: 20 additions & 25 deletions django/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,64 +8,57 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import User
from django.core.files.images import ImageFile
from django.core.exceptions import PermissionDenied
from django.core.files.images import ImageFile
from django.http import (
Http404,
HttpResponseBadRequest,
HttpResponseRedirect,
HttpResponseServerError,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import DetailView, TemplateView, RedirectView
from django.urls import reverse
from rest_framework import (
viewsets,
generics,
parsers,
mixins,
filters,
)
from django.views.generic import DetailView, RedirectView, TemplateView
from rest_framework import filters, generics, mixins, parsers, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
PermissionDenied as DrfPermissionDenied,
APIException,
NotAuthenticated,
NotFound,
APIException,
PermissionDenied as DrfPermissionDenied,
)
from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView, exception_handler
from taggit.models import Tag
from wagtail.images.models import Image

from library.models import Codebase
from .models import Event, FollowUser, Job, MemberProfile
from .serializers import (
EventSerializer,
JobSerializer,
MemberProfileSerializer,
RelatedMemberProfileSerializer,
TagSerializer,
)
from .discourse import build_discourse_url
from .mixins import (
CommonViewSetMixin,
HtmlListModelMixin,
HtmlRetrieveModelMixin,
PermissionRequiredByHttpMethodMixin,
SpamCatcherViewSetMixin,
)
from .models import Event, FollowUser, Job, MemberProfile
from .pagination import SmallResultSetPagination
from .permissions import ObjectPermissions, ViewRestrictedObjectPermissions
from .discourse import build_discourse_url
from .serializers import (
EventSerializer,
JobSerializer,
MemberProfileSerializer,
RelatedMemberProfileSerializer,
TagSerializer,
)
from .utils import parse_date, parse_datetime
from .view_helpers import (
add_user_retrieve_perms,
get_search_queryset,
retrieve_with_perms,
)
from .utils import parse_date, parse_datetime


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -316,7 +309,9 @@ def filter_queryset(self, request, queryset, view):
return get_search_queryset(qs, queryset, tags=tags)


class MemberProfileViewSet(CommonViewSetMixin, HtmlNoDeleteViewSet):
class MemberProfileViewSet(
SpamCatcherViewSetMixin, CommonViewSetMixin, HtmlNoDeleteViewSet
):
lookup_field = "user__pk"
lookup_url_kwarg = "pk"
queryset = MemberProfile.objects.public().with_tags()
Expand Down
19 changes: 18 additions & 1 deletion django/curator/serializers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from rest_framework import serializers

from core.models import Event, Job, SpamModeration
from core.models import Event, Job, MemberProfile, SpamModeration
from library.models import Codebase


Expand Down Expand Up @@ -56,6 +56,23 @@ class Meta:
]


class MinimalMemberProfileSerializer(serializers.ModelSerializer):
class Meta:
model = MemberProfile
fields = [
"id",
"username",
"name",
"email",
"bio",
"research_interests",
"affiliations_string",
"degrees",
"personal_url",
"professional_url",
]


class SpamUpdateSerializer(serializers.Serializer):
id = serializers.IntegerField()
is_spam = serializers.BooleanField()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
from rest_framework import status
from rest_framework.test import APIClient

from core.models import Event, Job, SpamModeration
from core.tests.base import BaseModelTestCase, EventFactory, JobFactory
from core.models import Event, Job, MemberProfile, SpamModeration
from core.tests.base import BaseModelTestCase, EventFactory, JobFactory, \
UserFactory
from library.models import Codebase
from library.tests.base import CodebaseFactory

Expand Down Expand Up @@ -42,14 +43,17 @@ def setUp(self):
title="Test Codebase", description="Codebase Description"
)

# Create SpamModeration objects
self.job_spam = SpamModeration.objects.create(
self.user_factory = UserFactory()
self.spammy_user = self.user_factory.create(username="scamlikely")

# Create SpamModeration objects (for MemberProfile the SpamModeration will be created automatically when user is created)
self.job_spam_moderation = SpamModeration.objects.create(
content_object=self.job, status=SpamModeration.Status.SCHEDULED_FOR_CHECK
)
self.event_spam = SpamModeration.objects.create(
self.event_spam_moderation = SpamModeration.objects.create(
content_object=self.event, status=SpamModeration.Status.SCHEDULED_FOR_CHECK
)
self.codebase_spam = SpamModeration.objects.create(
self.codebase_spam_moderation = SpamModeration.objects.create(
content_object=self.codebase,
status=SpamModeration.Status.SCHEDULED_FOR_CHECK,
)
Expand Down Expand Up @@ -101,13 +105,16 @@ def test_get_latest_spam_batch(self):
self.assertEqual(response.status_code, status.HTTP_200_OK)

data = response.json()
self.assertEqual(len(data), 3) # We expect 3 items in the batch
self.assertEqual(
len(data), 5
) # We expect 5 items in the batch (Event, Job, Codebase, MemberProfile) + MemberProfile of the test_user from super().setUp()

# Check if all content types are present
content_types = [item["contentType"] for item in data]
self.assertIn("job", content_types)
self.assertIn("event", content_types)
self.assertIn("codebase", content_types)
self.assertIn("memberprofile", content_types)

# Check structure of a job item
job_item = next(item for item in data if item["contentType"] == "job")
Expand Down Expand Up @@ -163,6 +170,40 @@ def test_update_spam_moderation_success(self):
# Check if related content object was updated
self.assertTrue(job.is_marked_spam)

def test_update_spam_moderation_success_memberprofile(self):
self.client.credentials(HTTP_X_API_KEY=self.api_key)

mp = MemberProfile.objects.get(id=self.spammy_user.member_profile.id)

data = {
"id": mp.spam_moderation.id,
"is_spam": True,
"spam_indicators": ["indicator1", "indicator2"],
"reasoning": "Test reasoning",
"confidence": 0.9,
}

response = self.client.post("/api/spam/update/", data, format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)

# Check if SpamModeration object was updated
mp.refresh_from_db()
self.assertIsNotNone(mp.spam_moderation)
self.assertEqual(mp.spam_moderation.status, SpamModeration.Status.SPAM_LIKELY)
self.assertTrue(mp.is_marked_spam)
self.assertEqual(mp.spam_moderation.detection_method, "LLM")
self.assertEqual(
mp.spam_moderation.detection_details["spam_indicators"],
["indicator1", "indicator2"],
)
self.assertEqual(
mp.spam_moderation.detection_details["reasoning"], "Test reasoning"
)
self.assertEqual(mp.spam_moderation.detection_details["confidence"], 0.9)

# Check if related content object was updated
self.assertTrue(mp.is_marked_spam)

def test_update_spam_moderation_not_spam(self):
self.client.credentials(HTTP_X_API_KEY=self.api_key)

Expand Down Expand Up @@ -194,7 +235,7 @@ def test_update_spam_moderation_invalid_data(self):
self.client.credentials(HTTP_X_API_KEY=self.api_key)

data = {
"id": self.codebase_spam.id,
"id": self.codebase_spam_moderation.id,
# Missing required 'is_spam' field
}

Expand All @@ -205,7 +246,7 @@ def test_update_spam_moderation_partial_update(self):
self.client.credentials(HTTP_X_API_KEY=self.api_key)

data = {
"id": self.codebase_spam.id,
"id": self.codebase_spam_moderation.id,
"is_spam": True,
# Only providing partial data
}
Expand Down
3 changes: 3 additions & 0 deletions django/curator/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
MinimalCodebaseSerializer,
MinimalEventSerializer,
MinimalJobSerializer,
MinimalMemberProfileSerializer,
SpamModerationSerializer,
SpamUpdateSerializer,
)
Expand Down Expand Up @@ -105,6 +106,8 @@ def get_latest_spam_batch(request):
content_serializer = MinimalEventSerializer(content_object)
elif content_type == "codebase":
content_serializer = MinimalCodebaseSerializer(content_object)
elif content_type == "memberprofile":
content_serializer = MinimalMemberProfileSerializer(content_object)
else:
continue

Expand Down
30 changes: 27 additions & 3 deletions django/home/signals.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import logging
import shortuuid

import shortuuid
from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.db.models.signals import post_save
from django.dispatch import receiver
from wagtail.models import Site as WagtailSite

from core.discourse import create_discourse_user
from core.models import MemberProfile, EXCLUDED_USERNAMES
from core.models import EXCLUDED_USERNAMES, MemberProfile, SpamModeration

logger = logging.getLogger(__name__)

Expand All @@ -34,6 +35,27 @@ def sync_discourse_user(user: User):
return success


def create_spam_moderation(mp: MemberProfile):
content_type = ContentType.objects.get_for_model(type(mp))
default_status = SpamModeration.Status.SCHEDULED_FOR_CHECK

default_spam_moderation = {
"status": default_status,
"detection_method": "",
"detection_details": "",
}

sm, created = SpamModeration.objects.update_or_create(
content_type=content_type,
object_id=mp.id,
defaults=default_spam_moderation,
)

# update the related object
mp.spam_moderation = sm
mp.save()


@receiver(post_save, sender=User, dispatch_uid="member_profile_sync")
def on_user_save(sender, instance: User, created, **kwargs):
"""
Expand All @@ -42,7 +64,9 @@ def on_user_save(sender, instance: User, created, **kwargs):
if instance.username in EXCLUDED_USERNAMES:
return
if created:
sync_member_profile(instance)
mp = sync_member_profile(instance)
if mp:
create_spam_moderation(mp)
if instance.email:
# sync with discourse
# to test discourse synchronization locally eliminate the DEPLOY_ENVIRONMENT check
Expand Down

0 comments on commit bde15a4

Please sign in to comment.