Skip to content

Commit bde15a4

Browse files
committed
fix: add SpamModeration to MemberProfile: when a User is created, a SpamModeration object is automatically created for the associated MemberProfile
1 parent 8b51468 commit bde15a4

File tree

7 files changed

+127
-42
lines changed

7 files changed

+127
-42
lines changed

django/core/mixins.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,14 +262,19 @@ def _validate_content_object(self, instance):
262262
"spam_moderation",
263263
"is_marked_spam",
264264
"get_absolute_url",
265-
"title",
266265
]
267266
for field in required_fields:
268267
if not hasattr(instance, field):
269268
raise ValueError(
270269
f"instance {instance} does not have a {field} attribute"
271270
)
272271

272+
# Ensure either 'title' () or 'username' (for MemberProfile) is present
273+
if not (hasattr(instance, "title") or hasattr(instance, "username")):
274+
raise ValueError(
275+
f"instance {instance} must have either a 'title' or a 'username' attribute"
276+
)
277+
273278
@action(detail=True, methods=["post"], permission_classes=[ModeratorPermissions])
274279
def mark_spam(self, request, **kwargs):
275280
instance = self.get_object()

django/core/models.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
from datetime import timedelta
2-
from enum import Enum
31
import logging
42
import pathlib
3+
from datetime import timedelta
4+
from enum import Enum
55

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

356356
@add_to_comses_permission_whitelist
357357
@register_snippet
358-
class MemberProfile(index.Indexed, ClusterableModel):
358+
class MemberProfile(index.Indexed, ModeratedContent, ClusterableModel):
359359
"""
360360
Contains additional comses.net information, possibly linked to a CoMSES Member / site account
361361
"""

django/core/views.py

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,64 +8,57 @@
88
from django.contrib.auth.decorators import login_required
99
from django.contrib.auth.mixins import LoginRequiredMixin
1010
from django.contrib.auth.models import User
11-
from django.core.files.images import ImageFile
1211
from django.core.exceptions import PermissionDenied
12+
from django.core.files.images import ImageFile
1313
from django.http import (
1414
Http404,
1515
HttpResponseBadRequest,
1616
HttpResponseRedirect,
1717
HttpResponseServerError,
1818
)
1919
from django.shortcuts import get_object_or_404, redirect, render
20-
from django.views.generic import DetailView, TemplateView, RedirectView
2120
from django.urls import reverse
22-
from rest_framework import (
23-
viewsets,
24-
generics,
25-
parsers,
26-
mixins,
27-
filters,
28-
)
21+
from django.views.generic import DetailView, RedirectView, TemplateView
22+
from rest_framework import filters, generics, mixins, parsers, viewsets
23+
from rest_framework.decorators import action
2924
from rest_framework.exceptions import (
30-
PermissionDenied as DrfPermissionDenied,
25+
APIException,
3126
NotAuthenticated,
3227
NotFound,
33-
APIException,
28+
PermissionDenied as DrfPermissionDenied,
3429
)
35-
from rest_framework.decorators import action
3630
from rest_framework.filters import OrderingFilter
37-
from rest_framework.permissions import IsAuthenticated, AllowAny
31+
from rest_framework.permissions import AllowAny, IsAuthenticated
3832
from rest_framework.response import Response
3933
from rest_framework.views import APIView, exception_handler
4034
from taggit.models import Tag
4135
from wagtail.images.models import Image
4236

4337
from library.models import Codebase
44-
from .models import Event, FollowUser, Job, MemberProfile
45-
from .serializers import (
46-
EventSerializer,
47-
JobSerializer,
48-
MemberProfileSerializer,
49-
RelatedMemberProfileSerializer,
50-
TagSerializer,
51-
)
38+
from .discourse import build_discourse_url
5239
from .mixins import (
5340
CommonViewSetMixin,
5441
HtmlListModelMixin,
5542
HtmlRetrieveModelMixin,
5643
PermissionRequiredByHttpMethodMixin,
5744
SpamCatcherViewSetMixin,
5845
)
46+
from .models import Event, FollowUser, Job, MemberProfile
5947
from .pagination import SmallResultSetPagination
6048
from .permissions import ObjectPermissions, ViewRestrictedObjectPermissions
61-
from .discourse import build_discourse_url
49+
from .serializers import (
50+
EventSerializer,
51+
JobSerializer,
52+
MemberProfileSerializer,
53+
RelatedMemberProfileSerializer,
54+
TagSerializer,
55+
)
56+
from .utils import parse_date, parse_datetime
6257
from .view_helpers import (
6358
add_user_retrieve_perms,
6459
get_search_queryset,
6560
retrieve_with_perms,
6661
)
67-
from .utils import parse_date, parse_datetime
68-
6962

7063
logger = logging.getLogger(__name__)
7164

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

318311

319-
class MemberProfileViewSet(CommonViewSetMixin, HtmlNoDeleteViewSet):
312+
class MemberProfileViewSet(
313+
SpamCatcherViewSetMixin, CommonViewSetMixin, HtmlNoDeleteViewSet
314+
):
320315
lookup_field = "user__pk"
321316
lookup_url_kwarg = "pk"
322317
queryset = MemberProfile.objects.public().with_tags()

django/curator/serializers.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from rest_framework import serializers
22

3-
from core.models import Event, Job, SpamModeration
3+
from core.models import Event, Job, MemberProfile, SpamModeration
44
from library.models import Codebase
55

66

@@ -56,6 +56,23 @@ class Meta:
5656
]
5757

5858

59+
class MinimalMemberProfileSerializer(serializers.ModelSerializer):
60+
class Meta:
61+
model = MemberProfile
62+
fields = [
63+
"id",
64+
"username",
65+
"name",
66+
"email",
67+
"bio",
68+
"research_interests",
69+
"affiliations_string",
70+
"degrees",
71+
"personal_url",
72+
"professional_url",
73+
]
74+
75+
5976
class SpamUpdateSerializer(serializers.Serializer):
6077
id = serializers.IntegerField()
6178
is_spam = serializers.BooleanField()

django/curator/tests/test_llm_spam_moderation.py renamed to django/curator/tests/test_llm_spam_moderation_api_endpoints.py

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
from rest_framework import status
66
from rest_framework.test import APIClient
77

8-
from core.models import Event, Job, SpamModeration
9-
from core.tests.base import BaseModelTestCase, EventFactory, JobFactory
8+
from core.models import Event, Job, MemberProfile, SpamModeration
9+
from core.tests.base import BaseModelTestCase, EventFactory, JobFactory, \
10+
UserFactory
1011
from library.models import Codebase
1112
from library.tests.base import CodebaseFactory
1213

@@ -42,14 +43,17 @@ def setUp(self):
4243
title="Test Codebase", description="Codebase Description"
4344
)
4445

45-
# Create SpamModeration objects
46-
self.job_spam = SpamModeration.objects.create(
46+
self.user_factory = UserFactory()
47+
self.spammy_user = self.user_factory.create(username="scamlikely")
48+
49+
# Create SpamModeration objects (for MemberProfile the SpamModeration will be created automatically when user is created)
50+
self.job_spam_moderation = SpamModeration.objects.create(
4751
content_object=self.job, status=SpamModeration.Status.SCHEDULED_FOR_CHECK
4852
)
49-
self.event_spam = SpamModeration.objects.create(
53+
self.event_spam_moderation = SpamModeration.objects.create(
5054
content_object=self.event, status=SpamModeration.Status.SCHEDULED_FOR_CHECK
5155
)
52-
self.codebase_spam = SpamModeration.objects.create(
56+
self.codebase_spam_moderation = SpamModeration.objects.create(
5357
content_object=self.codebase,
5458
status=SpamModeration.Status.SCHEDULED_FOR_CHECK,
5559
)
@@ -101,13 +105,16 @@ def test_get_latest_spam_batch(self):
101105
self.assertEqual(response.status_code, status.HTTP_200_OK)
102106

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

106112
# Check if all content types are present
107113
content_types = [item["contentType"] for item in data]
108114
self.assertIn("job", content_types)
109115
self.assertIn("event", content_types)
110116
self.assertIn("codebase", content_types)
117+
self.assertIn("memberprofile", content_types)
111118

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

173+
def test_update_spam_moderation_success_memberprofile(self):
174+
self.client.credentials(HTTP_X_API_KEY=self.api_key)
175+
176+
mp = MemberProfile.objects.get(id=self.spammy_user.member_profile.id)
177+
178+
data = {
179+
"id": mp.spam_moderation.id,
180+
"is_spam": True,
181+
"spam_indicators": ["indicator1", "indicator2"],
182+
"reasoning": "Test reasoning",
183+
"confidence": 0.9,
184+
}
185+
186+
response = self.client.post("/api/spam/update/", data, format="json")
187+
self.assertEqual(response.status_code, status.HTTP_200_OK)
188+
189+
# Check if SpamModeration object was updated
190+
mp.refresh_from_db()
191+
self.assertIsNotNone(mp.spam_moderation)
192+
self.assertEqual(mp.spam_moderation.status, SpamModeration.Status.SPAM_LIKELY)
193+
self.assertTrue(mp.is_marked_spam)
194+
self.assertEqual(mp.spam_moderation.detection_method, "LLM")
195+
self.assertEqual(
196+
mp.spam_moderation.detection_details["spam_indicators"],
197+
["indicator1", "indicator2"],
198+
)
199+
self.assertEqual(
200+
mp.spam_moderation.detection_details["reasoning"], "Test reasoning"
201+
)
202+
self.assertEqual(mp.spam_moderation.detection_details["confidence"], 0.9)
203+
204+
# Check if related content object was updated
205+
self.assertTrue(mp.is_marked_spam)
206+
166207
def test_update_spam_moderation_not_spam(self):
167208
self.client.credentials(HTTP_X_API_KEY=self.api_key)
168209

@@ -194,7 +235,7 @@ def test_update_spam_moderation_invalid_data(self):
194235
self.client.credentials(HTTP_X_API_KEY=self.api_key)
195236

196237
data = {
197-
"id": self.codebase_spam.id,
238+
"id": self.codebase_spam_moderation.id,
198239
# Missing required 'is_spam' field
199240
}
200241

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

207248
data = {
208-
"id": self.codebase_spam.id,
249+
"id": self.codebase_spam_moderation.id,
209250
"is_spam": True,
210251
# Only providing partial data
211252
}

django/curator/views.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
MinimalCodebaseSerializer,
2626
MinimalEventSerializer,
2727
MinimalJobSerializer,
28+
MinimalMemberProfileSerializer,
2829
SpamModerationSerializer,
2930
SpamUpdateSerializer,
3031
)
@@ -105,6 +106,8 @@ def get_latest_spam_batch(request):
105106
content_serializer = MinimalEventSerializer(content_object)
106107
elif content_type == "codebase":
107108
content_serializer = MinimalCodebaseSerializer(content_object)
109+
elif content_type == "memberprofile":
110+
content_serializer = MinimalMemberProfileSerializer(content_object)
108111
else:
109112
continue
110113

django/home/signals.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import logging
2-
import shortuuid
32

3+
import shortuuid
44
from django.conf import settings
55
from django.contrib.auth.models import User
6+
from django.contrib.contenttypes.models import ContentType
67
from django.contrib.sites.models import Site
78
from django.db.models.signals import post_save
89
from django.dispatch import receiver
910
from wagtail.models import Site as WagtailSite
1011

1112
from core.discourse import create_discourse_user
12-
from core.models import MemberProfile, EXCLUDED_USERNAMES
13+
from core.models import EXCLUDED_USERNAMES, MemberProfile, SpamModeration
1314

1415
logger = logging.getLogger(__name__)
1516

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

3637

38+
def create_spam_moderation(mp: MemberProfile):
39+
content_type = ContentType.objects.get_for_model(type(mp))
40+
default_status = SpamModeration.Status.SCHEDULED_FOR_CHECK
41+
42+
default_spam_moderation = {
43+
"status": default_status,
44+
"detection_method": "",
45+
"detection_details": "",
46+
}
47+
48+
sm, created = SpamModeration.objects.update_or_create(
49+
content_type=content_type,
50+
object_id=mp.id,
51+
defaults=default_spam_moderation,
52+
)
53+
54+
# update the related object
55+
mp.spam_moderation = sm
56+
mp.save()
57+
58+
3759
@receiver(post_save, sender=User, dispatch_uid="member_profile_sync")
3860
def on_user_save(sender, instance: User, created, **kwargs):
3961
"""
@@ -42,7 +64,9 @@ def on_user_save(sender, instance: User, created, **kwargs):
4264
if instance.username in EXCLUDED_USERNAMES:
4365
return
4466
if created:
45-
sync_member_profile(instance)
67+
mp = sync_member_profile(instance)
68+
if mp:
69+
create_spam_moderation(mp)
4670
if instance.email:
4771
# sync with discourse
4872
# to test discourse synchronization locally eliminate the DEPLOY_ENVIRONMENT check

0 commit comments

Comments
 (0)