* feat: Reapply "Integrate Forum V2 into edx-platform"
This reverts commit 818aa343a2.
* feat: make it possible to globally disable forum v2 with setting
We introduce a setting that allows us to bypass any course waffle flag
check. The advantage of such a setting is that we don't need to find the
course ID: in some cases, we might not have access to the course ID, and
we need to look for it... in forum v2.
See discussion here: https://github.com/openedx/forum/issues/137
* chore: bump openedx-forum to 0.1.5
This should fix an issue with index creation on edX.org.
907 lines
33 KiB
Python
907 lines
33 KiB
Python
"""
|
|
Discussion API serializers
|
|
"""
|
|
from typing import Dict
|
|
from urllib.parse import urlencode, urlunparse
|
|
|
|
from django.conf import settings
|
|
from django.contrib.auth import get_user_model
|
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
|
from django.db.models import TextChoices
|
|
from django.urls import reverse
|
|
from django.utils.html import strip_tags
|
|
from rest_framework import serializers
|
|
|
|
from common.djangoapps.student.models import get_user_by_username_or_email
|
|
from common.djangoapps.student.roles import GlobalStaff
|
|
from lms.djangoapps.discussion.django_comment_client.base.views import track_thread_lock_unlock_event, \
|
|
track_thread_edited_event, track_comment_edited_event, track_forum_response_mark_event
|
|
from lms.djangoapps.discussion.django_comment_client.utils import (
|
|
course_discussion_division_enabled,
|
|
get_group_id_for_user,
|
|
get_group_name,
|
|
is_comment_too_deep,
|
|
)
|
|
from lms.djangoapps.discussion.rest_api.permissions import (
|
|
NON_UPDATABLE_COMMENT_FIELDS,
|
|
NON_UPDATABLE_THREAD_FIELDS,
|
|
can_delete,
|
|
get_editable_fields,
|
|
)
|
|
from lms.djangoapps.discussion.rest_api.render import render_body
|
|
from lms.djangoapps.discussion.rest_api.utils import (
|
|
get_course_staff_users_list,
|
|
get_moderator_users_list,
|
|
get_course_ta_users_list,
|
|
)
|
|
from openedx.core.djangoapps.discussions.models import DiscussionTopicLink
|
|
from openedx.core.djangoapps.discussions.utils import get_group_names_by_id
|
|
from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment
|
|
from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread
|
|
from openedx.core.djangoapps.django_comment_common.comment_client.user import User as CommentClientUser
|
|
from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientRequestError
|
|
from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings
|
|
from openedx.core.djangoapps.user_api.accounts.api import get_profile_images
|
|
from openedx.core.lib.api.serializers import CourseKeyField
|
|
|
|
User = get_user_model()
|
|
|
|
CLOSE_REASON_CODES = getattr(settings, "DISCUSSION_MODERATION_CLOSE_REASON_CODES", {})
|
|
EDIT_REASON_CODES = getattr(settings, "DISCUSSION_MODERATION_EDIT_REASON_CODES", {})
|
|
|
|
|
|
class TopicOrdering(TextChoices):
|
|
"""
|
|
Enum for the available options for ordering topics.
|
|
"""
|
|
COURSE_STRUCTURE = "course_structure", "Course Structure"
|
|
ACTIVITY = "activity", "Activity"
|
|
NAME = "name", "Name"
|
|
|
|
|
|
def get_context(course, request, thread=None):
|
|
"""
|
|
Returns a context appropriate for use with ThreadSerializer or
|
|
(if thread is provided) CommentSerializer.
|
|
"""
|
|
course_staff_user_ids = get_course_staff_users_list(course.id)
|
|
moderator_user_ids = get_moderator_users_list(course.id)
|
|
ta_user_ids = get_course_ta_users_list(course.id)
|
|
requester = request.user
|
|
cc_requester = CommentClientUser.from_django_user(requester).retrieve(course_id=course.id)
|
|
cc_requester["course_id"] = course.id
|
|
course_discussion_settings = CourseDiscussionSettings.get(course.id)
|
|
is_global_staff = GlobalStaff().has_user(requester)
|
|
has_moderation_privilege = requester.id in moderator_user_ids or requester.id in ta_user_ids or is_global_staff
|
|
return {
|
|
"course": course,
|
|
"request": request,
|
|
"thread": thread,
|
|
"discussion_division_enabled": course_discussion_division_enabled(course_discussion_settings),
|
|
"group_ids_to_names": get_group_names_by_id(course_discussion_settings),
|
|
"moderator_user_ids": moderator_user_ids,
|
|
"course_staff_user_ids": course_staff_user_ids,
|
|
"ta_user_ids": ta_user_ids,
|
|
"cc_requester": cc_requester,
|
|
"has_moderation_privilege": has_moderation_privilege,
|
|
"is_global_staff": is_global_staff,
|
|
"is_staff_or_admin": requester.id in course_staff_user_ids,
|
|
}
|
|
|
|
|
|
def validate_not_blank(value):
|
|
"""
|
|
Validate that a value is not an empty string or whitespace.
|
|
|
|
Raises: ValidationError
|
|
"""
|
|
if not value.strip():
|
|
raise ValidationError("This field may not be blank.")
|
|
|
|
|
|
def validate_edit_reason_code(value):
|
|
"""
|
|
Validate that the value is a valid edit reason code.
|
|
|
|
Raises: ValidationError
|
|
"""
|
|
if value not in EDIT_REASON_CODES:
|
|
raise ValidationError("Invalid edit reason code")
|
|
|
|
|
|
def validate_close_reason_code(value):
|
|
"""
|
|
Validate that the value is a valid close reason code.
|
|
|
|
Raises: ValidationError
|
|
"""
|
|
if value not in CLOSE_REASON_CODES:
|
|
raise ValidationError("Invalid close reason code")
|
|
|
|
|
|
def _validate_privileged_access(context: Dict) -> bool:
|
|
"""
|
|
Return the field specified by ``field_name`` if requesting user is privileged.
|
|
|
|
Checks that the course exists in the context, and that the user has privileged
|
|
access.
|
|
|
|
Args:
|
|
context (Dict): The serializer context.
|
|
|
|
Returns:
|
|
bool: Course exists and the user has privileged access.
|
|
"""
|
|
course = context.get('course', None)
|
|
is_requester_privileged = context.get('has_moderation_privilege')
|
|
return course and is_requester_privileged
|
|
|
|
|
|
class _ContentSerializer(serializers.Serializer):
|
|
# pylint: disable=abstract-method
|
|
"""
|
|
A base class for thread and comment serializers.
|
|
"""
|
|
id = serializers.CharField(read_only=True) # pylint: disable=invalid-name
|
|
author = serializers.SerializerMethodField()
|
|
author_label = serializers.SerializerMethodField()
|
|
created_at = serializers.CharField(read_only=True)
|
|
updated_at = serializers.CharField(read_only=True)
|
|
raw_body = serializers.CharField(source="body", validators=[validate_not_blank])
|
|
rendered_body = serializers.SerializerMethodField()
|
|
abuse_flagged = serializers.SerializerMethodField()
|
|
voted = serializers.SerializerMethodField()
|
|
vote_count = serializers.SerializerMethodField()
|
|
editable_fields = serializers.SerializerMethodField()
|
|
can_delete = serializers.SerializerMethodField()
|
|
anonymous = serializers.BooleanField(default=False)
|
|
anonymous_to_peers = serializers.BooleanField(default=False)
|
|
last_edit = serializers.SerializerMethodField(required=False)
|
|
edit_reason_code = serializers.CharField(required=False, validators=[validate_edit_reason_code])
|
|
edit_by_label = serializers.SerializerMethodField(required=False)
|
|
|
|
non_updatable_fields = set()
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self._rendered_body = None
|
|
|
|
for field in self.non_updatable_fields:
|
|
setattr(self, f"validate_{field}", self._validate_non_updatable)
|
|
|
|
def _validate_non_updatable(self, value):
|
|
"""Ensure that a field is not edited in an update operation."""
|
|
if self.instance:
|
|
raise ValidationError("This field is not allowed in an update.")
|
|
return value
|
|
|
|
def _is_user_privileged(self, user_id):
|
|
"""
|
|
Returns a boolean indicating whether the given user_id identifies a
|
|
privileged user.
|
|
"""
|
|
return user_id in self.context["moderator_user_ids"] or user_id in self.context["ta_user_ids"]
|
|
|
|
def _is_anonymous(self, obj):
|
|
"""
|
|
Returns a boolean indicating whether the content should be anonymous to
|
|
the requester.
|
|
"""
|
|
user_id = self.context["request"].user.id
|
|
is_user_staff = user_id in self.context["moderator_user_ids"] or user_id in self.context["ta_user_ids"]
|
|
|
|
return (
|
|
obj["anonymous"] or
|
|
obj["anonymous_to_peers"] and not is_user_staff
|
|
)
|
|
|
|
def get_author(self, obj):
|
|
"""
|
|
Returns the author's username, or None if the content is anonymous.
|
|
"""
|
|
return None if self._is_anonymous(obj) else obj["username"]
|
|
|
|
def _get_user_label(self, user_id):
|
|
"""
|
|
Returns the role label (i.e. "Staff", "Moderator" or "Community TA") for the user
|
|
with the given id.
|
|
"""
|
|
is_staff = user_id in self.context["course_staff_user_ids"]
|
|
is_moderator = user_id in self.context["moderator_user_ids"]
|
|
is_ta = user_id in self.context["ta_user_ids"]
|
|
|
|
return (
|
|
"Staff" if is_staff else
|
|
"Moderator" if is_moderator else
|
|
"Community TA" if is_ta else
|
|
None
|
|
)
|
|
|
|
def _get_user_label_from_username(self, username):
|
|
"""
|
|
Returns role label of user from username
|
|
Possible Role Labels: Staff, Moderator, Community TA or None
|
|
"""
|
|
try:
|
|
user = User.objects.get(username=username)
|
|
return self._get_user_label(user.id)
|
|
except ObjectDoesNotExist:
|
|
return None
|
|
|
|
def get_author_label(self, obj):
|
|
"""
|
|
Returns the role label for the content author.
|
|
"""
|
|
if self._is_anonymous(obj) or obj["user_id"] is None:
|
|
return None
|
|
else:
|
|
user_id = int(obj["user_id"])
|
|
return self._get_user_label(user_id)
|
|
|
|
def get_rendered_body(self, obj):
|
|
"""
|
|
Returns the rendered body content.
|
|
"""
|
|
if self._rendered_body is None:
|
|
self._rendered_body = render_body(obj["body"])
|
|
return self._rendered_body
|
|
|
|
def get_abuse_flagged(self, obj):
|
|
"""
|
|
Returns a boolean indicating whether the requester has flagged the
|
|
content as abusive.
|
|
"""
|
|
total_abuse_flaggers = len(obj.get("abuse_flaggers", []))
|
|
return (
|
|
self.context["has_moderation_privilege"] and total_abuse_flaggers > 0 or
|
|
self.context["cc_requester"]["id"] in obj.get("abuse_flaggers", [])
|
|
)
|
|
|
|
def get_voted(self, obj):
|
|
"""
|
|
Returns a boolean indicating whether the requester has voted for the
|
|
content.
|
|
"""
|
|
return obj["id"] in self.context["cc_requester"]["upvoted_ids"]
|
|
|
|
def get_vote_count(self, obj):
|
|
"""
|
|
Returns the number of votes for the content.
|
|
"""
|
|
return obj.get("votes", {}).get("up_count", 0)
|
|
|
|
def get_editable_fields(self, obj):
|
|
"""
|
|
Return the list of the fields the requester can edit
|
|
"""
|
|
return sorted(get_editable_fields(obj, self.context))
|
|
|
|
def get_can_delete(self, obj):
|
|
"""
|
|
Returns if the current user can delete this thread/comment.
|
|
"""
|
|
return can_delete(obj, self.context)
|
|
|
|
def get_last_edit(self, obj):
|
|
"""
|
|
Returns information about the last edit for this content for
|
|
privileged users.
|
|
"""
|
|
is_user_author = str(obj['user_id']) == str(self.context['request'].user.id)
|
|
if not (_validate_privileged_access(self.context) or is_user_author):
|
|
return None
|
|
edit_history = obj.get("edit_history")
|
|
if not edit_history:
|
|
return None
|
|
last_edit = edit_history[-1]
|
|
reason_code = last_edit.get("reason_code")
|
|
if reason_code:
|
|
last_edit["reason"] = EDIT_REASON_CODES.get(reason_code)
|
|
return last_edit
|
|
|
|
def get_edit_by_label(self, obj):
|
|
"""
|
|
Returns the role label for the last edit user.
|
|
"""
|
|
is_user_author = str(obj['user_id']) == str(self.context['request'].user.id)
|
|
is_user_privileged = _validate_privileged_access(self.context)
|
|
edit_history = obj.get("edit_history")
|
|
if (is_user_author or is_user_privileged) and edit_history:
|
|
last_edit = edit_history[-1]
|
|
return self._get_user_label_from_username(last_edit.get('editor_username'))
|
|
|
|
|
|
class ThreadSerializer(_ContentSerializer):
|
|
"""
|
|
A serializer for thread data.
|
|
|
|
N.B. This should not be used with a comment_client Thread object that has
|
|
not had retrieve() called, because of the interaction between DRF's attempts
|
|
at introspection and Thread's __getattr__.
|
|
"""
|
|
course_id = serializers.CharField()
|
|
topic_id = serializers.CharField(source="commentable_id", validators=[validate_not_blank])
|
|
group_id = serializers.IntegerField(required=False, allow_null=True)
|
|
group_name = serializers.SerializerMethodField()
|
|
type = serializers.ChoiceField(
|
|
source="thread_type",
|
|
choices=[(val, val) for val in ["discussion", "question"]]
|
|
)
|
|
preview_body = serializers.SerializerMethodField()
|
|
abuse_flagged_count = serializers.SerializerMethodField(required=False)
|
|
title = serializers.CharField(validators=[validate_not_blank])
|
|
pinned = serializers.SerializerMethodField()
|
|
closed = serializers.BooleanField(required=False)
|
|
following = serializers.SerializerMethodField()
|
|
comment_count = serializers.SerializerMethodField(read_only=True)
|
|
unread_comment_count = serializers.SerializerMethodField(read_only=True)
|
|
comment_list_url = serializers.SerializerMethodField()
|
|
endorsed_comment_list_url = serializers.SerializerMethodField()
|
|
non_endorsed_comment_list_url = serializers.SerializerMethodField()
|
|
read = serializers.BooleanField(required=False)
|
|
has_endorsed = serializers.BooleanField(source="endorsed", read_only=True)
|
|
response_count = serializers.IntegerField(source="resp_total", read_only=True, required=False)
|
|
close_reason_code = serializers.CharField(required=False, validators=[validate_close_reason_code])
|
|
close_reason = serializers.SerializerMethodField()
|
|
closed_by = serializers.SerializerMethodField()
|
|
closed_by_label = serializers.SerializerMethodField(required=False)
|
|
|
|
non_updatable_fields = NON_UPDATABLE_THREAD_FIELDS
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
# Compensate for the fact that some threads in the comments service do
|
|
# not have the pinned field set
|
|
if self.instance and self.instance.get("pinned") is None:
|
|
self.instance["pinned"] = False
|
|
|
|
def get_abuse_flagged_count(self, obj):
|
|
"""
|
|
Returns the number of users that flagged content as abusive only if user has staff permissions
|
|
"""
|
|
if _validate_privileged_access(self.context):
|
|
return obj.get("abuse_flagged_count")
|
|
|
|
def get_pinned(self, obj):
|
|
"""
|
|
Compensate for the fact that some threads in the comments service do
|
|
not have the pinned field set.
|
|
"""
|
|
return bool(obj["pinned"])
|
|
|
|
def get_group_name(self, obj):
|
|
"""
|
|
Returns the name of the group identified by the thread's group_id.
|
|
"""
|
|
return self.context["group_ids_to_names"].get(obj["group_id"])
|
|
|
|
def get_following(self, obj):
|
|
"""
|
|
Returns a boolean indicating whether the requester is following the
|
|
thread.
|
|
"""
|
|
return obj["id"] in self.context["cc_requester"]["subscribed_thread_ids"]
|
|
|
|
def get_comment_list_url(self, obj, endorsed=None):
|
|
"""
|
|
Returns the URL to retrieve the thread's comments, optionally including
|
|
the endorsed query parameter.
|
|
"""
|
|
if (
|
|
(obj["thread_type"] == "question" and endorsed is None) or
|
|
(obj["thread_type"] == "discussion" and endorsed is not None)
|
|
):
|
|
return None
|
|
path = reverse("comment-list")
|
|
query_dict = {"thread_id": obj["id"]}
|
|
if endorsed is not None:
|
|
query_dict["endorsed"] = endorsed
|
|
return self.context["request"].build_absolute_uri(
|
|
urlunparse(("", "", path, "", urlencode(query_dict), ""))
|
|
)
|
|
|
|
def get_endorsed_comment_list_url(self, obj):
|
|
"""
|
|
Returns the URL to retrieve the thread's endorsed comments.
|
|
"""
|
|
return self.get_comment_list_url(obj, endorsed=True)
|
|
|
|
def get_non_endorsed_comment_list_url(self, obj):
|
|
"""
|
|
Returns the URL to retrieve the thread's non-endorsed comments.
|
|
"""
|
|
return self.get_comment_list_url(obj, endorsed=False)
|
|
|
|
def get_comment_count(self, obj):
|
|
"""
|
|
Increments comment count to include post and returns total count of
|
|
contributions (i.e. post + responses + comments) for the thread
|
|
"""
|
|
return obj["comments_count"] + 1
|
|
|
|
def get_unread_comment_count(self, obj):
|
|
"""
|
|
Returns the number of unread comments. If the thread has never been read,
|
|
this additionally includes 1 for the post itself, in addition to its responses and
|
|
comments.
|
|
"""
|
|
if not obj["read"] and obj["comments_count"] == obj["unread_comments_count"]:
|
|
return obj["unread_comments_count"] + 1
|
|
return obj["unread_comments_count"]
|
|
|
|
def get_preview_body(self, obj):
|
|
"""
|
|
Returns a cleaned version of the thread's body to display in a preview capacity.
|
|
"""
|
|
return strip_tags(self.get_rendered_body(obj)).replace('\n', ' ').replace(' ', ' ')
|
|
|
|
def get_close_reason(self, obj):
|
|
"""
|
|
Returns the reason for which the thread was closed.
|
|
"""
|
|
is_user_author = str(obj['user_id']) == str(self.context['request'].user.id)
|
|
if not (_validate_privileged_access(self.context) or is_user_author):
|
|
return None
|
|
reason_code = obj.get("close_reason_code")
|
|
return CLOSE_REASON_CODES.get(reason_code)
|
|
|
|
def get_closed_by(self, obj):
|
|
"""
|
|
Returns the username of the moderator who closed this thread,
|
|
only to other privileged users and author.
|
|
"""
|
|
is_user_author = str(obj['user_id']) == str(self.context['request'].user.id)
|
|
if _validate_privileged_access(self.context) or is_user_author:
|
|
return obj.get("closed_by")
|
|
|
|
def get_closed_by_label(self, obj):
|
|
"""
|
|
Returns the role label for the user who closed the post.
|
|
"""
|
|
is_user_author = str(obj['user_id']) == str(self.context['request'].user.id)
|
|
if is_user_author or _validate_privileged_access(self.context):
|
|
return self._get_user_label_from_username(obj.get("closed_by"))
|
|
|
|
def create(self, validated_data):
|
|
thread = Thread(user_id=self.context["cc_requester"]["id"], **validated_data)
|
|
thread.save()
|
|
return thread
|
|
|
|
def update(self, instance, validated_data):
|
|
for key, val in validated_data.items():
|
|
instance[key] = val
|
|
requesting_user_id = self.context["cc_requester"]["id"]
|
|
if key == "closed" and val:
|
|
instance["closing_user_id"] = requesting_user_id
|
|
track_thread_lock_unlock_event(self.context['request'], self.context['course'],
|
|
instance, validated_data.get('close_reason_code'))
|
|
|
|
if key == "closed" and not val:
|
|
instance["closing_user_id"] = requesting_user_id
|
|
track_thread_lock_unlock_event(self.context['request'], self.context['course'],
|
|
instance, validated_data.get('close_reason_code'), locked=False)
|
|
|
|
if key == "body" and val:
|
|
instance["editing_user_id"] = requesting_user_id
|
|
track_thread_edited_event(self.context['request'], self.context['course'],
|
|
instance, validated_data.get('edit_reason_code'))
|
|
instance.save()
|
|
return instance
|
|
|
|
|
|
class CommentSerializer(_ContentSerializer):
|
|
"""
|
|
A serializer for comment data.
|
|
|
|
N.B. This should not be used with a comment_client Comment object that has
|
|
not had retrieve() called, because of the interaction between DRF's attempts
|
|
at introspection and Comment's __getattr__.
|
|
"""
|
|
thread_id = serializers.CharField()
|
|
parent_id = serializers.CharField(required=False, allow_null=True)
|
|
endorsed = serializers.BooleanField(required=False)
|
|
endorsed_by = serializers.SerializerMethodField()
|
|
endorsed_by_label = serializers.SerializerMethodField()
|
|
endorsed_at = serializers.SerializerMethodField()
|
|
child_count = serializers.IntegerField(read_only=True)
|
|
children = serializers.SerializerMethodField(required=False)
|
|
abuse_flagged_any_user = serializers.SerializerMethodField(required=False)
|
|
profile_image = serializers.SerializerMethodField(read_only=True)
|
|
|
|
non_updatable_fields = NON_UPDATABLE_COMMENT_FIELDS
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
remove_fields = kwargs.pop('remove_fields', None)
|
|
super().__init__(*args, **kwargs)
|
|
|
|
if remove_fields:
|
|
# for multiple fields in a list
|
|
for field_name in remove_fields:
|
|
self.fields.pop(field_name)
|
|
|
|
def get_endorsed_by(self, obj):
|
|
"""
|
|
Returns the username of the endorsing user, if the information is
|
|
available and would not identify the author of an anonymous thread.
|
|
This information is unavailable outside the thread context.
|
|
"""
|
|
if not self.context.get("thread"):
|
|
return None
|
|
endorsement = obj.get("endorsement")
|
|
if endorsement:
|
|
endorser_id = int(endorsement["user_id"])
|
|
# Avoid revealing the identity of an anonymous non-staff question
|
|
# author who has endorsed a comment in the thread
|
|
if not (
|
|
self._is_anonymous(self.context["thread"]) and
|
|
not self._is_user_privileged(endorser_id)
|
|
):
|
|
return User.objects.get(id=endorser_id).username
|
|
return None
|
|
|
|
def get_endorsed_by_label(self, obj):
|
|
"""
|
|
Returns the role label (i.e. "Staff" or "Community TA") for the
|
|
endorsing user.
|
|
This information is unavailable outside the thread context.
|
|
"""
|
|
if not self.context.get("thread"):
|
|
return None
|
|
endorsement = obj.get("endorsement")
|
|
if endorsement:
|
|
return self._get_user_label(int(endorsement["user_id"]))
|
|
else:
|
|
return None
|
|
|
|
def get_endorsed_at(self, obj):
|
|
"""
|
|
Returns the timestamp for the endorsement, if available.
|
|
This information is unavailable outside the thread context.
|
|
"""
|
|
if not self.context.get("thread"):
|
|
return None
|
|
endorsement = obj.get("endorsement")
|
|
return endorsement["time"] if endorsement else None
|
|
|
|
def get_children(self, obj):
|
|
return [
|
|
CommentSerializer(child, context=self.context).data
|
|
for child in obj.get("children", [])
|
|
]
|
|
|
|
def to_representation(self, data):
|
|
# pylint: disable=arguments-differ
|
|
data = super().to_representation(data)
|
|
|
|
# Django Rest Framework v3 no longer includes None values
|
|
# in the representation. To maintain the previous behavior,
|
|
# we do this manually instead.
|
|
if 'parent_id' not in data:
|
|
data["parent_id"] = None
|
|
|
|
return data
|
|
|
|
def get_abuse_flagged_any_user(self, obj):
|
|
"""
|
|
Returns a boolean indicating whether any user has flagged the
|
|
content as abusive.
|
|
"""
|
|
if _validate_privileged_access(self.context):
|
|
return len(obj.get("abuse_flaggers", [])) > 0
|
|
|
|
def get_profile_image(self, obj):
|
|
request = self.context["request"]
|
|
return get_profile_images(request.user.profile, request.user, request)
|
|
|
|
def validate(self, attrs):
|
|
"""
|
|
Ensure that parent_id identifies a comment that is actually in the
|
|
thread identified by thread_id and does not violate the configured
|
|
maximum depth.
|
|
"""
|
|
parent = None
|
|
parent_id = attrs.get("parent_id")
|
|
if parent_id:
|
|
try:
|
|
parent = Comment(id=parent_id).retrieve()
|
|
except CommentClientRequestError:
|
|
pass
|
|
if not (parent and parent["thread_id"] == attrs["thread_id"]):
|
|
raise ValidationError(
|
|
"parent_id does not identify a comment in the thread identified by thread_id."
|
|
)
|
|
if is_comment_too_deep(parent):
|
|
raise ValidationError("Comment level is too deep.")
|
|
return attrs
|
|
|
|
def create(self, validated_data):
|
|
comment = Comment(
|
|
course_id=self.context["thread"]["course_id"],
|
|
user_id=self.context["cc_requester"]["id"],
|
|
**validated_data
|
|
)
|
|
comment.save()
|
|
return comment
|
|
|
|
def update(self, instance, validated_data):
|
|
for key, val in validated_data.items():
|
|
instance[key] = val
|
|
# TODO: The comments service doesn't populate the endorsement
|
|
# field on comment creation, so we only provide
|
|
# endorsement_user_id on update
|
|
requesting_user_id = self.context["cc_requester"]["id"]
|
|
if key == "endorsed":
|
|
track_forum_response_mark_event(self.context['request'], self.context['course'], instance, val)
|
|
instance["endorsement_user_id"] = requesting_user_id
|
|
if key == "body" and val:
|
|
instance["editing_user_id"] = requesting_user_id
|
|
track_comment_edited_event(self.context['request'], self.context['course'],
|
|
instance, validated_data.get('edit_reason_code'))
|
|
|
|
instance.save()
|
|
return instance
|
|
|
|
|
|
class DiscussionTopicSerializer(serializers.Serializer):
|
|
"""
|
|
Serializer for DiscussionTopic
|
|
"""
|
|
id = serializers.CharField(read_only=True) # pylint: disable=invalid-name
|
|
name = serializers.CharField(read_only=True)
|
|
thread_list_url = serializers.CharField(read_only=True)
|
|
children = serializers.SerializerMethodField()
|
|
thread_counts = serializers.DictField(read_only=True)
|
|
|
|
def get_children(self, obj):
|
|
"""
|
|
Returns a list of children of DiscussionTopicSerializer type
|
|
"""
|
|
if not obj.children:
|
|
return []
|
|
return [DiscussionTopicSerializer(child).data for child in obj.children]
|
|
|
|
def create(self, validated_data):
|
|
"""
|
|
Overriden create abstract method
|
|
"""
|
|
|
|
def update(self, instance, validated_data):
|
|
"""
|
|
Overriden update abstract method
|
|
"""
|
|
|
|
|
|
class DiscussionTopicSerializerV2(serializers.Serializer):
|
|
"""
|
|
Serializer for new style topics.
|
|
"""
|
|
id = serializers.CharField( # pylint: disable=invalid-name
|
|
read_only=True,
|
|
source="external_id",
|
|
help_text="Provider-specific unique id for the topic"
|
|
)
|
|
usage_key = serializers.CharField(
|
|
read_only=True,
|
|
help_text="Usage context for the topic",
|
|
)
|
|
name = serializers.CharField(
|
|
read_only=True,
|
|
source="title",
|
|
help_text="Topic name",
|
|
)
|
|
thread_counts = serializers.SerializerMethodField(
|
|
read_only=True,
|
|
help_text="Mapping of thread counts by type of thread",
|
|
)
|
|
enabled_in_context = serializers.BooleanField(
|
|
read_only=True,
|
|
help_text="Whether this topic is enabled in its context",
|
|
)
|
|
|
|
def get_thread_counts(self, obj: DiscussionTopicLink) -> Dict[str, int]:
|
|
"""
|
|
Get thread counts from provided context
|
|
"""
|
|
return self.context['thread_counts'].get(obj.external_id, {
|
|
"discussion": 0,
|
|
"question": 0,
|
|
})
|
|
|
|
|
|
class DiscussionRolesSerializer(serializers.Serializer):
|
|
"""
|
|
Serializer for course discussion roles.
|
|
"""
|
|
|
|
ACTION_CHOICES = (
|
|
('allow', 'allow'),
|
|
('revoke', 'revoke')
|
|
)
|
|
action = serializers.ChoiceField(ACTION_CHOICES)
|
|
user_id = serializers.CharField()
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.user = None
|
|
|
|
def validate_user_id(self, user_id):
|
|
"""
|
|
Validate user id
|
|
Args:
|
|
user_id (str): username or email
|
|
|
|
Returns:
|
|
str: user id if valid
|
|
"""
|
|
try:
|
|
self.user = get_user_by_username_or_email(user_id)
|
|
return user_id
|
|
except User.DoesNotExist as err:
|
|
raise ValidationError(f"'{user_id}' is not a valid student identifier") from err
|
|
|
|
def validate(self, attrs):
|
|
"""Validate the data at an object level."""
|
|
|
|
# Store the user object to avoid fetching it again.
|
|
if hasattr(self, 'user'):
|
|
attrs['user'] = self.user
|
|
return attrs
|
|
|
|
def create(self, validated_data):
|
|
"""
|
|
Overriden create abstract method
|
|
"""
|
|
|
|
def update(self, instance, validated_data):
|
|
"""
|
|
Overriden update abstract method
|
|
"""
|
|
|
|
|
|
class DiscussionRolesMemberSerializer(serializers.Serializer):
|
|
"""
|
|
Serializer for course discussion roles member data.
|
|
"""
|
|
username = serializers.CharField()
|
|
email = serializers.EmailField()
|
|
first_name = serializers.CharField()
|
|
last_name = serializers.CharField()
|
|
group_name = serializers.SerializerMethodField()
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
super().__init__(*args, **kwargs)
|
|
self.course_discussion_settings = self.context['course_discussion_settings']
|
|
|
|
def get_group_name(self, instance):
|
|
"""Return the group name of the user."""
|
|
group_id = get_group_id_for_user(instance, self.course_discussion_settings)
|
|
group_name = get_group_name(group_id, self.course_discussion_settings)
|
|
return group_name
|
|
|
|
def create(self, validated_data):
|
|
"""
|
|
Overriden create abstract method
|
|
"""
|
|
|
|
def update(self, instance, validated_data):
|
|
"""
|
|
Overriden update abstract method
|
|
"""
|
|
|
|
|
|
class DiscussionRolesListSerializer(serializers.Serializer):
|
|
"""
|
|
Serializer for course discussion roles member list.
|
|
"""
|
|
course_id = serializers.CharField()
|
|
results = serializers.SerializerMethodField()
|
|
division_scheme = serializers.SerializerMethodField()
|
|
|
|
def get_results(self, obj):
|
|
"""Return the nested serializer data representing a list of member users."""
|
|
context = {
|
|
'course_id': obj['course_id'],
|
|
'course_discussion_settings': self.context['course_discussion_settings']
|
|
}
|
|
serializer = DiscussionRolesMemberSerializer(obj['users'], context=context, many=True)
|
|
return serializer.data
|
|
|
|
def get_division_scheme(self, obj): # pylint: disable=unused-argument
|
|
"""Return the division scheme for the course."""
|
|
return self.context['course_discussion_settings'].division_scheme
|
|
|
|
def create(self, validated_data):
|
|
"""
|
|
Overridden create abstract method
|
|
"""
|
|
|
|
def update(self, instance, validated_data):
|
|
"""
|
|
Overridden update abstract method
|
|
"""
|
|
|
|
|
|
class UserStatsSerializer(serializers.Serializer):
|
|
"""
|
|
Serializer for course user stats.
|
|
"""
|
|
threads = serializers.IntegerField()
|
|
replies = serializers.IntegerField()
|
|
responses = serializers.IntegerField()
|
|
active_flags = serializers.IntegerField()
|
|
inactive_flags = serializers.IntegerField()
|
|
username = serializers.CharField()
|
|
|
|
def to_representation(self, instance):
|
|
"""Remove flag counts if user is not privileged."""
|
|
data = super().to_representation(instance)
|
|
if not self.context.get("is_privileged", False):
|
|
data["active_flags"] = None
|
|
data["inactive_flags"] = None
|
|
return data
|
|
|
|
|
|
class BlackoutDateSerializer(serializers.Serializer):
|
|
"""
|
|
Serializer for blackout dates.
|
|
"""
|
|
start = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the start of the blackout period")
|
|
end = serializers.DateTimeField(help_text="The ISO 8601 timestamp for the end of the blackout period")
|
|
|
|
|
|
class ReasonCodeSeralizer(serializers.Serializer):
|
|
"""
|
|
Serializer for reason codes.
|
|
"""
|
|
code = serializers.CharField(help_text="A code for the an edit or close reason")
|
|
label = serializers.CharField(help_text="A user-friendly name text for the close or edit reason")
|
|
|
|
|
|
class CourseMetadataSerailizer(serializers.Serializer):
|
|
"""
|
|
Serializer for course metadata.
|
|
"""
|
|
id = CourseKeyField(help_text="The identifier of the course")
|
|
blackouts = serializers.ListField(
|
|
child=BlackoutDateSerializer(),
|
|
help_text="A list of objects representing blackout periods "
|
|
"(during which discussions are read-only except for privileged users)."
|
|
)
|
|
thread_list_url = serializers.URLField(
|
|
help_text="The URL of the list of all threads in the course.",
|
|
)
|
|
following_thread_list_url = serializers.URLField(
|
|
help_text="thread_list_url with parameter following=True",
|
|
)
|
|
topics_url = serializers.URLField(help_text="The URL of the topic listing for the course.")
|
|
allow_anonymous = serializers.BooleanField(
|
|
help_text="A boolean indicating whether anonymous posts are allowed or not.",
|
|
)
|
|
allow_anonymous_to_peers = serializers.BooleanField(
|
|
help_text="A boolean indicating whether posts anonymous to peers are allowed or not.",
|
|
)
|
|
user_roles = serializers.ListField(
|
|
child=serializers.CharField(),
|
|
help_text="A list of all the roles the requesting user has for this course.",
|
|
)
|
|
user_is_privileged = serializers.BooleanField(
|
|
help_text="A boolean indicating if the current user has a privileged role",
|
|
)
|
|
provider = serializers.CharField(
|
|
help_text="The discussion provider used by this course",
|
|
)
|
|
enable_in_context = serializers.BooleanField(
|
|
help_text="A boolean indicating whether in-context discussion is enabled for the course",
|
|
)
|
|
group_at_subsection = serializers.BooleanField(
|
|
help_text="A boolean indicating whether discussions should be grouped at subsection",
|
|
)
|
|
post_close_reasons = serializers.ListField(
|
|
child=ReasonCodeSeralizer(),
|
|
help_text="A list of reasons that can be specified by moderators for closing a post",
|
|
)
|
|
edit_reasons = serializers.ListField(
|
|
child=ReasonCodeSeralizer(),
|
|
help_text="A list of reasons that can be specified by moderators for editing a post, response, or comment",
|
|
)
|