Files
edx-platform/lms/djangoapps/discussion/rest_api/serializers.py
edX requirements bot f33f12bbea BOM-2358 : Pyupgrade in dashboard, debug, discussion apps (#26529)
* Python code cleanup by the cleanup-python-code Jenkins job.

This pull request was generated by the cleanup-python-code Jenkins job, which ran
```
cd lms/djangoapps/dashboard; find . -type f -name '*.py' | while read fname; do sed -i 's/  # lint-amnesty, pylint: disable=super-with-arguments//; s/  # lint-amnesty, pylint: disable=import-error, wrong-import-order//; s/  # lint-amnesty, pylint: disable=wrong-import-order//' "$fname"; done; find . -type f -name '*.py' | while read fname; do pyupgrade --exit-zero-even-if-changed --py3-plus --py36-plus --py38-plus "$fname"; done; isort --recursive .
```

The following packages were installed:
`pyupgrade,isort`

* feedback done

Co-authored-by: Zulqarnain <muhammad.zulqarnain@arbisoft.com>
2021-02-22 15:42:21 +05:00

631 lines
22 KiB
Python

"""
Discussion API serializers
"""
from django.contrib.auth.models import User as DjangoUser # lint-amnesty, pylint: disable=imported-auth-user
from django.core.exceptions import ValidationError
from django.urls import reverse
from rest_framework import serializers
from six.moves.urllib.parse import urlencode, urlunparse
from common.djangoapps.student.models import get_user_by_username_or_email
from lms.djangoapps.discussion.django_comment_client.utils import (
course_discussion_division_enabled,
get_group_id_for_user,
get_group_name,
get_group_names_by_id,
is_comment_too_deep
)
from lms.djangoapps.discussion.rest_api.permissions import (
NON_UPDATABLE_COMMENT_FIELDS,
NON_UPDATABLE_THREAD_FIELDS,
get_editable_fields
)
from lms.djangoapps.discussion.rest_api.render import render_body
from lms.djangoapps.discussion.views import get_divided_discussions
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 (
FORUM_ROLE_ADMINISTRATOR,
FORUM_ROLE_COMMUNITY_TA,
FORUM_ROLE_MODERATOR,
Role
)
from openedx.core.djangoapps.django_comment_common.utils import get_course_discussion_settings
def get_context(course, request, thread=None):
"""
Returns a context appropriate for use with ThreadSerializer or
(if thread is provided) CommentSerializer.
"""
# TODO: cache staff_user_ids and ta_user_ids if we need to improve perf
staff_user_ids = {
user.id
for role in Role.objects.filter(
name__in=[FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR],
course_id=course.id
)
for user in role.users.all()
}
ta_user_ids = {
user.id
for role in Role.objects.filter(name=FORUM_ROLE_COMMUNITY_TA, course_id=course.id)
for user in role.users.all()
}
requester = request.user
cc_requester = CommentClientUser.from_django_user(requester).retrieve()
cc_requester["course_id"] = course.id
course_discussion_settings = get_course_discussion_settings(course.id)
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),
"is_requester_privileged": requester.id in staff_user_ids or requester.id in ta_user_ids,
"staff_user_ids": staff_user_ids,
"ta_user_ids": ta_user_ids,
"cc_requester": cc_requester,
}
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.")
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()
non_updatable_fields = set()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
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["staff_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.
"""
return (
obj["anonymous"] or
obj["anonymous_to_peers"] and not self.context["is_requester_privileged"]
)
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" or "Community TA") for the user
with the given id.
"""
return (
"Staff" if user_id in self.context["staff_user_ids"] else
"Community TA" if user_id in self.context["ta_user_ids"] else
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.
"""
return render_body(obj["body"])
def get_abuse_flagged(self, obj):
"""
Returns a boolean indicating whether the requester has flagged the
content as abusive.
"""
return 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))
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"]]
)
title = serializers.CharField(validators=[validate_not_blank])
pinned = serializers.SerializerMethodField(read_only=True)
closed = serializers.BooleanField(read_only=True)
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)
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_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 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
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)
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.
"""
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 DjangoUser.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
"""
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.
"""
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 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
if key == "endorsed":
instance["endorsement_user_id"] = self.context["cc_requester"]["id"]
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()
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
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def update(self, instance, validated_data):
"""
Overriden update abstract method
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
class DiscussionSettingsSerializer(serializers.Serializer):
"""
Serializer for course discussion settings.
"""
divided_course_wide_discussions = serializers.ListField(
child=serializers.CharField(),
)
divided_inline_discussions = serializers.ListField(
child=serializers.CharField(),
)
always_divide_inline_discussions = serializers.BooleanField()
division_scheme = serializers.CharField()
def __init__(self, *args, **kwargs):
self.course = kwargs.pop('course')
self.discussion_settings = kwargs.pop('discussion_settings')
super().__init__(*args, **kwargs)
def validate(self, attrs):
"""
Validate the fields in combination.
"""
if not any(field in attrs for field in self.fields):
raise ValidationError('Bad request')
settings_to_change = {}
divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions(
self.course, self.discussion_settings
)
if any(item in attrs for item in ('divided_course_wide_discussions', 'divided_inline_discussions')):
divided_course_wide_discussions = attrs.get(
'divided_course_wide_discussions',
divided_course_wide_discussions
)
divided_inline_discussions = attrs.get('divided_inline_discussions', divided_inline_discussions)
settings_to_change['divided_discussions'] = divided_course_wide_discussions + divided_inline_discussions
for item in ('always_divide_inline_discussions', 'division_scheme'):
if item in attrs:
settings_to_change[item] = attrs[item]
attrs['settings_to_change'] = settings_to_change
return attrs
def create(self, validated_data):
"""
Overriden create abstract method
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def update(self, instance, validated_data):
"""
Overriden update abstract method
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
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): # lint-amnesty, pylint: disable=missing-function-docstring
try:
self.user = get_user_by_username_or_email(user_id)
return user_id
except DjangoUser.DoesNotExist:
raise ValidationError(f"'{user_id}' is not a valid student identifier") # lint-amnesty, pylint: disable=raise-missing-from
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
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def update(self, instance, validated_data):
"""
Overriden update abstract method
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
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
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def update(self, instance, validated_data):
"""
Overriden update abstract method
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
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):
"""
Overriden create abstract method
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass
def update(self, instance, validated_data):
"""
Overriden update abstract method
"""
pass # lint-amnesty, pylint: disable=unnecessary-pass