Skip to content

Commit

Permalink
Merge branch 'main' of https://github.com/edx/learning-assistant into…
Browse files Browse the repository at this point in the history
… bilalqamar95/python-upgrade
  • Loading branch information
BilalQamar95 committed Nov 15, 2024
2 parents 1ddf3bf + e370923 commit 5b5f4ff
Show file tree
Hide file tree
Showing 16 changed files with 738 additions and 17 deletions.
25 changes: 25 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,32 @@ Change Log
Unreleased
**********

4.4.5 - 2024-11-12
******************
* Updated Learning Assistant History payload to return in ascending order

4.4.4 - 2024-11-06
******************
* Fixed Learning Assistant History endpoint
* Added timestamp to the Learning Assistant History payload

4.4.3 - 2024-11-06
******************
* Fixed package version

4.4.2 - 2024-11-04
******************
* Added chat messages to the DB

4.4.1 - 2024-10-31
******************
* Add management command to remove expired messages

4.4.0 - 2024-10-30
******************
* Add LearningAssistantMessage model
* Add new GET endpoint to retrieve a user's message history in a given course.

4.4.0 - 2024-10-25
******************
Expand Down
2 changes: 1 addition & 1 deletion learning_assistant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
Plugin for a learning assistant backend, intended for use within edx-platform.
"""

__version__ = '4.3.3'
__version__ = '4.4.5'

default_app_config = 'learning_assistant.apps.LearningAssistantConfig' # pylint: disable=invalid-name
39 changes: 38 additions & 1 deletion learning_assistant/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
import logging

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from edx_django_utils.cache import get_cache_key
from jinja2 import BaseLoader, Environment
from opaque_keys import InvalidKeyError

from learning_assistant.constants import ACCEPTED_CATEGORY_TYPES, CATEGORY_TYPE_MAP
from learning_assistant.data import LearningAssistantCourseEnabledData
from learning_assistant.models import LearningAssistantCourseEnabled
from learning_assistant.models import LearningAssistantCourseEnabled, LearningAssistantMessage
from learning_assistant.platform_imports import (
block_get_children,
block_leaf_filter,
Expand All @@ -24,6 +25,7 @@
from learning_assistant.text_utils import html_to_text

log = logging.getLogger(__name__)
User = get_user_model()


def _extract_block_contents(child, category):
Expand Down Expand Up @@ -187,3 +189,38 @@ def get_course_id(course_run_id):
course_data = get_cache_course_run_data(course_run_id, ['course'])
course_key = course_data['course']
return course_key


def save_chat_message(courserun_key, user_id, chat_role, message):
"""
Save the chat message to the database.
"""
user = None
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist as exc:
raise Exception("User does not exists.") from exc

# Save the user message to the database.
LearningAssistantMessage.objects.create(
course_id=courserun_key,
user=user,
role=chat_role,
content=message,

)


def get_message_history(courserun_key, user, message_count):
"""
Given a courserun key (CourseKey), user (User), and message count (int), return the associated message history.
Returns a number of messages equal to the message_count value.
"""
# Explanation over the double reverse: This fetches the last message_count elements ordered by creating order DESC.
# Slicing the list in the model is an equivalent of adding LIMIT on the query.
# The result is the last chat messages for that user and course but in inversed order, so in order to flip them
# its first turn into a list and then reversed.
message_history = list(LearningAssistantMessage.objects.filter(
course_id=courserun_key, user=user).order_by('-created')[:message_count])[::-1]
return message_history
68 changes: 68 additions & 0 deletions learning_assistant/management/commands/retire_user_messages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
""""
Django management command to remove LearningAssistantMessage objects
if they have reached their expiration date.
"""
import logging
import time
from datetime import datetime, timedelta

from django.conf import settings
from django.core.management.base import BaseCommand

from learning_assistant.models import LearningAssistantMessage

log = logging.getLogger(__name__)


class Command(BaseCommand):
"""
Django Management command to remove expired messages.
"""

def add_arguments(self, parser):
parser.add_argument(
'--batch_size',
action='store',
dest='batch_size',
type=int,
default=300,
help='Maximum number of messages to remove. '
'This helps avoid overloading the database while updating large amount of data.'
)
parser.add_argument(
'--sleep_time',
action='store',
dest='sleep_time',
type=int,
default=10,
help='Sleep time in seconds between update of batches'
)

def handle(self, *args, **options):
"""
Management command entry point.
"""
batch_size = options['batch_size']
sleep_time = options['sleep_time']

expiry_date = datetime.now() - timedelta(days=getattr(settings, 'LEARNING_ASSISTANT_MESSAGES_EXPIRY', 30))

total_deleted = 0
deleted_count = None

while deleted_count != 0:
ids_to_delete = LearningAssistantMessage.objects.filter(
created__lte=expiry_date
).values_list('id', flat=True)[:batch_size]

ids_to_delete = list(ids_to_delete)
delete_queryset = LearningAssistantMessage.objects.filter(
id__in=ids_to_delete
)
deleted_count, _ = delete_queryset.delete()

total_deleted += deleted_count
log.info(f'{deleted_count} messages deleted.')
time.sleep(sleep_time)

log.info(f'Job completed. {total_deleted} messages deleted.')
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""
Tests for the retire_user_messages management command
"""
from datetime import datetime, timedelta

from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import TestCase

from learning_assistant.models import LearningAssistantMessage

User = get_user_model()


class RetireUserMessagesTests(TestCase):
"""
Tests for the retire_user_messages command.
"""

def setUp(self):
"""
Build up test data
"""
super().setUp()
self.user = User(username='tester', email='[email protected]')
self.user.save()

self.course_id = 'course-v1:edx+test+23'

LearningAssistantMessage.objects.create(
user=self.user,
course_id=self.course_id,
role='user',
content='Hello',
created=datetime.now() - timedelta(days=60)
)

LearningAssistantMessage.objects.create(
user=self.user,
course_id=self.course_id,
role='user',
content='Hello',
created=datetime.now() - timedelta(days=2)
)

LearningAssistantMessage.objects.create(
user=self.user,
course_id=self.course_id,
role='user',
content='Hello',
created=datetime.now() - timedelta(days=4)
)

def test_run_command(self):
"""
Run the management command
"""
current_messages = LearningAssistantMessage.objects.filter()
self.assertEqual(len(current_messages), 3)

call_command(
'retire_user_messages',
batch_size=2,
sleep_time=0,
)

current_messages = LearningAssistantMessage.objects.filter()
self.assertEqual(len(current_messages), 2)
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.14 on 2024-11-04 08:52

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('learning_assistant', '0007_learningassistantmessage'),
]

operations = [
migrations.AlterField(
model_name='learningassistantmessage',
name='role',
field=models.CharField(choices=[('user', 'user'), ('assistant', 'assistant')], max_length=64),
),
]
31 changes: 31 additions & 0 deletions learning_assistant/migrations/0009_learningassistantaudittrial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Generated by Django 4.2.16 on 2024-11-14 13:55

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import model_utils.fields


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('learning_assistant', '0008_alter_learningassistantmessage_role'),
]

operations = [
migrations.CreateModel(
name='LearningAssistantAuditTrial',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')),
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')),
('start_date', models.DateTimeField()),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, unique=True)),
],
options={
'abstract': False,
},
),
]
25 changes: 24 additions & 1 deletion learning_assistant/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,30 @@ class LearningAssistantMessage(TimeStampedModel):
.. pii_retirement: third_party
"""

USER_ROLE = 'user'
ASSISTANT_ROLE = 'assistant'

Roles = (
(USER_ROLE, USER_ROLE),
(ASSISTANT_ROLE, ASSISTANT_ROLE),
)

course_id = CourseKeyField(max_length=255, db_index=True)
user = models.ForeignKey(USER_MODEL, db_index=True, on_delete=models.CASCADE)
role = models.CharField(max_length=64)
role = models.CharField(choices=Roles, max_length=64)
content = models.TextField()


class LearningAssistantAuditTrial(TimeStampedModel):
"""
This model stores the trial period for an audit learner using the learning assistant.
A LearningAssistantAuditTrial instance will be created on a per user basis,
when an audit learner first sends a message using Xpert LA.
.. no_pii: This model has no PII.
"""

# Unique constraint since each user should only have one trial
user = models.ForeignKey(USER_MODEL, db_index=True, on_delete=models.CASCADE, unique=True)
start_date = models.DateTimeField()
17 changes: 16 additions & 1 deletion learning_assistant/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"""
from rest_framework import serializers

from learning_assistant.models import LearningAssistantMessage


class MessageSerializer(serializers.Serializer): # pylint: disable=abstract-method
"""
Expand All @@ -11,12 +13,25 @@ class MessageSerializer(serializers.Serializer): # pylint: disable=abstract-met

role = serializers.CharField(required=True)
content = serializers.CharField(required=True)
timestamp = serializers.DateTimeField(required=False, source='created')

class Meta:
"""
Serializer metadata.
"""

model = LearningAssistantMessage
fields = (
'role',
'content',
'timestamp',
)

def validate_role(self, value):
"""
Validate that role is one of two acceptable values.
"""
valid_roles = ['user', 'assistant']
valid_roles = [LearningAssistantMessage.USER_ROLE, LearningAssistantMessage.ASSISTANT_ROLE]
if value not in valid_roles:
raise serializers.ValidationError('Must be valid role.')
return value
2 changes: 1 addition & 1 deletion learning_assistant/text_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def cleanup_text(text):
return stripped


class _HTMLToTextHelper(HTMLParser):
class _HTMLToTextHelper(HTMLParser): # lint-amnesty
"""
Helper function for html_to_text below.
"""
Expand Down
17 changes: 17 additions & 0 deletions learning_assistant/toggles.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
# .. toggle_tickets: COSMO-80
ENABLE_COURSE_CONTENT = 'enable_course_content'

# .. toggle_name: learning_assistant.enable_chat_history
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable the chat history with the learning assistant
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2024-10-30
# .. toggle_target_removal_date: 2024-12-31
# .. toggle_tickets: COSMO-436
ENABLE_CHAT_HISTORY = 'enable_chat_history'


def _is_learning_assistant_waffle_flag_enabled(flag_name, course_key):
"""
Expand All @@ -32,3 +42,10 @@ def course_content_enabled(course_key):
Return whether the learning_assistant.enable_course_content WaffleFlag is on.
"""
return _is_learning_assistant_waffle_flag_enabled(ENABLE_COURSE_CONTENT, course_key)


def chat_history_enabled(course_key):
"""
Return whether the learning_assistant.enable_chat_history WaffleFlag is on.
"""
return _is_learning_assistant_waffle_flag_enabled(ENABLE_CHAT_HISTORY, course_key)
Loading

0 comments on commit 5b5f4ff

Please sign in to comment.