Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: unify datetime serialization #193

Merged
merged 1 commit into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions futurex_openedx_extensions/dashboard/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@
COURSE_STATUS_SELF_PREFIX,
COURSE_STATUSES,
)
from futurex_openedx_extensions.helpers.converters import relative_url_to_absolute_url
from futurex_openedx_extensions.helpers.converters import (
DEFAULT_DATETIME_FORMAT,
dt_to_str,
relative_url_to_absolute_url,
)
from futurex_openedx_extensions.helpers.export_csv import get_exported_file_url
from futurex_openedx_extensions.helpers.models import DataExportTask
from futurex_openedx_extensions.helpers.roles import (
Expand Down Expand Up @@ -203,11 +207,11 @@ def get_username(self, obj: get_user_model) -> str:

def get_date_joined(self, obj: Any) -> str | None:
date_joined = self._get_user(obj).date_joined # type: ignore
return date_joined.isoformat() if date_joined else None
return dt_to_str(date_joined)

def get_last_login(self, obj: Any) -> str | None:
last_login = self._get_user(obj).last_login # type: ignore
return last_login.isoformat() if last_login else None
return dt_to_str(last_login)

def get_national_id(self, obj: get_user_model) -> Any:
"""Return national ID."""
Expand Down Expand Up @@ -534,11 +538,11 @@ def get_status(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use

def get_start_enrollment_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use
"""Return the start enrollment date."""
return obj.enrollment_start
return dt_to_str(obj.enrollment_start)

def get_end_enrollment_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use
"""Return the end enrollment date."""
return obj.enrollment_end
return dt_to_str(obj.enrollment_end)

def get_image_url(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use
"""Return the course image URL."""
Expand All @@ -550,11 +554,11 @@ def get_tenant_ids(self, obj: CourseOverview) -> Any: # pylint: disable=no-self

def get_start_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use
"""Return the start date."""
return obj.start
return dt_to_str(obj.start)

def get_end_date(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use
"""Return the end date."""
return obj.end
return dt_to_str(obj.end)


class CourseDetailsSerializer(CourseDetailsBaseSerializer):
Expand Down Expand Up @@ -582,8 +586,8 @@ def get_rating(self, obj: CourseOverview) -> Any: # pylint: disable=no-self-use

class LearnerCoursesDetailsSerializer(CourseDetailsBaseSerializer):
"""Serializer for learner's courses details."""
enrollment_date = serializers.DateTimeField()
last_activity = serializers.DateTimeField()
enrollment_date = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT)
last_activity = serializers.DateTimeField(format=DEFAULT_DATETIME_FORMAT)
certificate_url = serializers.SerializerMethodField()
progress_url = serializers.SerializerMethodField()
grades_url = serializers.SerializerMethodField()
Expand Down
9 changes: 8 additions & 1 deletion futurex_openedx_extensions/helpers/converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
from __future__ import annotations

import re
from datetime import datetime, timedelta
from datetime import date, datetime, timedelta
from typing import Any, Callable, Dict, List
from urllib.parse import urljoin

from dateutil.relativedelta import relativedelta # type: ignore

from futurex_openedx_extensions.helpers import constants as cs

DEFAULT_DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ'


def ids_string_to_list(ids_string: str) -> List[int]:
"""Convert a comma-separated string of ids to a list of integers. Duplicate ids are not removed."""
Expand Down Expand Up @@ -205,3 +207,8 @@ def get_allowed_roles(roles_filter: List[str] | None) -> Dict[str, List[str]]:
allowed_roles[role] = list(set(allowed_roles[role]).intersection(roles_filter))

return allowed_roles


def dt_to_str(datetime_or_date: datetime | date) -> str | None:
"""Convert a datetime object to a string"""
return datetime_or_date.strftime(DEFAULT_DATETIME_FORMAT) if datetime_or_date else None
13 changes: 7 additions & 6 deletions tests/test_dashboard/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
UserRolesSerializer,
)
from futurex_openedx_extensions.helpers import constants as cs
from futurex_openedx_extensions.helpers.converters import dt_to_str
from futurex_openedx_extensions.helpers.models import DataExportTask
from futurex_openedx_extensions.helpers.roles import RoleType

Expand Down Expand Up @@ -606,10 +607,10 @@ def test_course_details_base_serializer(base_data): # pylint: disable=unused-ar

assert data['id'] == str(course.id)
assert data['self_paced'] == course.self_paced
assert data['start_date'] == course.start
assert data['end_date'] == course.end
assert data['start_enrollment_date'] == course.enrollment_start
assert data['end_enrollment_date'] == course.enrollment_end
assert data['start_date'] == dt_to_str(course.start)
assert data['end_date'] == dt_to_str(course.end)
assert data['start_enrollment_date'] == dt_to_str(course.enrollment_start)
assert data['end_enrollment_date'] == dt_to_str(course.enrollment_end)
assert data['display_name'] == course.display_name
assert data['image_url'] == 'https://example.com/image.jpg'
assert data['org'] == course.org
Expand Down Expand Up @@ -721,8 +722,8 @@ def test_learner_courses_details_serializer(base_data): # pylint: disable=unuse
data = LearnerCoursesDetailsSerializer(course, context={'request': request}).data

assert data['id'] == str(course.id)
assert data['enrollment_date'] == enrollment_date.isoformat()
assert data['last_activity'] == last_activity.isoformat()
assert data['enrollment_date'] == dt_to_str(enrollment_date)
assert data['last_activity'] == dt_to_str(last_activity)
assert data['progress_url'] == f'https://test.com/learning/course/{course.id}/progress/{course.related_user_id}/'
assert data['grades_url'] == f'https://test.com/gradebook/{course.id}/'
assert data['progress'] == completion_summary
Expand Down
13 changes: 12 additions & 1 deletion tests/test_helpers/test_converters.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@

"""Tests for converters helpers."""
from datetime import date
from datetime import date, datetime
from unittest.mock import Mock, patch

import pytest
Expand Down Expand Up @@ -140,3 +140,14 @@ def test_date_methods_valid_supported_methods():
method = method_parts[0]
assert hasattr(DateMethods, method), f'DateMethods.DATE_METHODS contains a non-existing method! ({method})'
assert all(not item for item in method_parts[1:]), f'Bad DateMethods.DATE_METHODS format! ({method_id})'


@pytest.mark.parametrize('value, expected_result', [
(date(2023, 12, 26), '2023-12-26T00:00:00Z'),
(datetime(2023, 12, 26, 12, 30, 45), '2023-12-26T12:30:45Z'),
(datetime(2023, 12, 26, 12, 30, 45).replace(microsecond=315), '2023-12-26T12:30:45Z'),
(None, None),
])
def test_dt_to_str(value, expected_result):
"""Verify that dt_to_str return the correct string."""
assert converters.dt_to_str(value) == expected_result