Skip to content
Closed
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
5 changes: 3 additions & 2 deletions assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@
"I18N_ASSESSMENT_INSTRUCTION_NEW_ATTEMPT": "In that case, you can start the assessment again later, which will begin a new attempt.",
"I18N_ASSESSMENT_INSTRUCTION_ONE_QUESTION_AT_A_TIME": "You'll see one question at a time.",
"I18N_ASSESSMENT_INSTRUCTION_PROGRESS_NOT_SAVED": "If your internet connection drops or you exit the assessment before submitting, your progress will not be saved.",
"I18N_ASSESSMENT_INSTRUCTION_QUESTION_COUNT": "The assessment contains <[questions]> questions.",
"I18N_ASSESSMENT_INSTRUCTION_REVIEW_ANSWERS": "You can review and change your answers at any time before submitting.",
"I18N_ASSESSMENT_INSTRUCTION_TIME_LIMIT": "You'll have <[minutes]> minutes to complete the assessment.",
"I18N_ASSESSMENT_INSTRUCTION_UNANSWERED_MARKED_INCORRECT": "Unanswered questions will be marked as Incorrect answers.",
Expand Down Expand Up @@ -1767,8 +1768,8 @@
"I18N_VIEW_HINT_WITH_INDEX_BUTTON_TEXT": "View hint <[index]>",
"I18N_VIEW_SOLUTION_BUTTON_TEXT": "View Solution",
"I18N_VOLUNTEER_PAGE_BREADCRUMB": "Volunteer",
"I18N_VOLUNTEER_PAGE_FOOTER": "If you're interested in helping out, let us know your skills and interests and a team lead will reach out to you shortly!",
"I18N_VOLUNTEER_PAGE_SECTION_ONE_BUTTON": "Volunteer with Oppia",
"I18N_VOLUNTEER_PAGE_FOOTER": "Want to get involved? Check out our Idealist page below to see where our different teams need help, and apply for the specific role that matches your skills and interests!",
"I18N_VOLUNTEER_PAGE_SECTION_ONE_BUTTON": "Explore Volunteer Openings",
"I18N_VOLUNTEER_PAGE_SECTION_ONE_CONTENT": "Improve access to education for millions of students. It doesn't matter where you come from, what language you speak, or how young or old you are - all are welcome!",
"I18N_VOLUNTEER_PAGE_SECTION_ONE_HEADING": "Volunteer to make a difference",
"I18N_VOLUNTEER_PAGE_SKILLS_ART_AND_DESIGN_SET1_HEADING": "Producing graphics, and defining design initiatives (UX/UI)",
Expand Down
4 changes: 2 additions & 2 deletions assets/i18n/qqq.json
Original file line number Diff line number Diff line change
Expand Up @@ -1767,8 +1767,8 @@
"I18N_VIEW_HINT_WITH_INDEX_BUTTON_TEXT": "Label for the button to view a specific hint, showing its sequence number.",
"I18N_VIEW_SOLUTION_BUTTON_TEXT": "Label for the button that shows the solution when clicked.",
"I18N_VOLUNTEER_PAGE_BREADCRUMB": "Text displayed in the Volunteer page. - Text shown in the top left corner of the nav bar.",
"I18N_VOLUNTEER_PAGE_FOOTER": "Text in the footer section of the volunteer page. It provides github repo link.",
"I18N_VOLUNTEER_PAGE_SECTION_ONE_BUTTON": "Fill out interest form button in section one.",
"I18N_VOLUNTEER_PAGE_FOOTER": "Text in the footer section of the volunteer page. It invites the user to check out the Oppia Idealist page for volunteering opportunities.",
"I18N_VOLUNTEER_PAGE_SECTION_ONE_BUTTON": "Button that links to the Oppia Idealist page with volunteering opportunities.",
"I18N_VOLUNTEER_PAGE_SECTION_ONE_CONTENT": "Text content under the title in section one. -First paragraph",
"I18N_VOLUNTEER_PAGE_SECTION_ONE_HEADING": "Title of section one in volunteer page.",
"I18N_VOLUNTEER_PAGE_SKILLS_ART_AND_DESIGN_SET1_HEADING": "Heading for the art and design tab skill set-1 in the volunteer section of volunteer page.",
Expand Down
57 changes: 57 additions & 0 deletions core/controllers/acl_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2884,6 +2884,63 @@ def test_login(
return test_login


def can_access_certificate_assessment_attempt_result(
handler: Callable[..., _GenericHandlerFunctionReturnType],
) -> Callable[..., _GenericHandlerFunctionReturnType]:
"""Decorator that checks whether the user can access a certificate
assessment attempt result.

Args:
handler: function. The function to be decorated.

Returns:
function. The newly decorated function that now also checks
whether the user can access the given attempt.
"""

# Here we use type Any because this method can accept arbitrary number of
# arguments with different types.
@functools.wraps(handler)
def test_can_access_certificate_assessment_attempt_result(
self: _SelfBaseHandlerType, attempt_id: str, **kwargs: Any
) -> _GenericHandlerFunctionReturnType:
"""Checks whether the user can access the given attempt.

Args:
attempt_id: str. The ID of the certificate assessment attempt.
**kwargs: *. Keyword arguments.

Returns:
*. The return value of the decorated function.

Raises:
NotLoggedInException. The user is not logged in.
NotFoundException. The attempt does not exist.
UnauthorizedUserException. The user does not own the attempt.
"""
if not self.user_id:
raise base.UserFacingExceptions.NotLoggedInException

try:
attempt = certificate_assessment_services.get_certificate_attempt(
attempt_id
)
except (
certificate_assessment_services.CertificateAssessmentAttemptNotFoundException
) as e:
raise self.NotFoundException(str(e)) from e

if attempt.learner_id != self.user_id:
raise self.UnauthorizedUserException(
'You do not have permission to access this certificate '
'assessment attempt result.'
)

return handler(self, attempt_id, **kwargs)

return test_can_access_certificate_assessment_attempt_result


def can_edit_topic(
handler: Callable[..., _GenericHandlerFunctionReturnType],
) -> Callable[..., _GenericHandlerFunctionReturnType]:
Expand Down
107 changes: 104 additions & 3 deletions core/controllers/acl_decorators_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,17 @@
certificate_assessment_offering_models,
datastore_services,
secrets_services,
suggestion_models,
)

datastore_services = models.Registry.import_datastore_services()
secrets_services = models.Registry.import_secrets_services()
(certificate_assessment_offering_models,) = models.Registry.import_models(
[models.Names.CERTIFICATE_ASSESSMENT_OFFERING]
(
certificate_assessment_offering_models,
suggestion_models,
) = models.Registry.import_models(
[models.Names.CERTIFICATE_ASSESSMENT_OFFERING, models.Names.SUGGESTION]
)
(suggestion_models,) = models.Registry.import_models([models.Names.SUGGESTION])


class OpenAccessDecoratorTests(test_utils.GenericTestBase):
Expand Down Expand Up @@ -726,6 +730,103 @@ def test_guest_user_is_redirected_to_homepage(self) -> None:
self.assertEqual('http://localhost/', response.headers['location'])


class CertificateAssessmentAttemptResultAccessDecoratorTests(
test_utils.GenericTestBase
):
"""Tests for can_access_certificate_assessment_attempt_result decorator."""

username = 'user'
user_email = 'user@example.com'
other_username = 'otheruser'
other_email = 'otheruser@example.com'

class MockHandler(base.BaseHandler[Dict[str, str], Dict[str, str]]):
GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON
URL_PATH_ARGS_SCHEMAS = {
'attempt_id': {'schema': {'type': 'basestring'}}
}
HANDLER_ARGS_SCHEMAS: Dict[str, Dict[str, str]] = {'GET': {}}

@acl_decorators.can_access_certificate_assessment_attempt_result
def get(self, attempt_id: str) -> None:
self.render_json({'attempt_id': attempt_id})

def setUp(self) -> None:
super().setUp()
self.signup(self.user_email, self.username)
self.signup(self.other_email, self.other_username)
self.user_id = self.get_user_id_from_email(self.user_email)

self.mock_testapp = webtest.TestApp(
webapp2.WSGIApplication(
[
webapp2.Route(
'/mock_certificate_attempt/<attempt_id>',
self.MockHandler,
)
],
debug=feconf.DEBUG,
)
)
self.attempt = certificate_assessment_offering_models.CertificateAssessmentAttemptModel.create(
learner_id=self.user_id,
total_score=80.0,
attempt_index=1,
attempt_data={
'topic_id_101': {
'total_related_questions': 5,
'total_correct_questions': 3,
}
},
version_data={
'certificate_id': 'cert_abc123',
'certificate_version': 1,
'topic_versions': {'topic_id_101': 2},
'question_versions': {'question_id_1': 1},
'question_topic_links': {'question_id_1': ['topic_id_101']},
},
started_at=datetime.datetime(2026, 7, 18),
finished_at=None,
is_submitted=True,
)

def test_attempt_owner_can_access_attempt(self) -> None:
self.login(self.user_email)
with self.swap(self, 'testapp', self.mock_testapp):
response = self.get_json(
'/mock_certificate_attempt/%s' % self.attempt.id
)
self.assertEqual(response['attempt_id'], self.attempt.id)
self.logout()

def test_other_user_cannot_access_attempt(self) -> None:
self.login(self.other_email)
with self.swap(self, 'testapp', self.mock_testapp):
self.get_json(
'/mock_certificate_attempt/%s' % self.attempt.id,
expected_status_int=401,
)
self.logout()

def test_guest_user_cannot_access_attempt(self) -> None:
with self.swap(self, 'testapp', self.mock_testapp):
response = self.get_json(
'/mock_certificate_attempt/%s' % self.attempt.id,
expected_status_int=401,
)
error_msg = 'You must be logged in to access this resource.'
self.assertEqual(response['error'], error_msg)

def test_missing_attempt_returns_404(self) -> None:
self.login(self.user_email)
with self.swap(self, 'testapp', self.mock_testapp):
self.get_json(
'/mock_certificate_attempt/missing_attempt_id',
expected_status_int=404,
)
self.logout()


class PlayExplorationDecoratorTests(test_utils.GenericTestBase):
"""Tests for play exploration decorator."""

Expand Down
128 changes: 91 additions & 37 deletions core/controllers/certificate_assessment.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,28 @@

from __future__ import annotations

import datetime

from core import feconf, utils
from core.controllers import acl_decorators, base
from core.controllers import domain_objects_validator as validation_method
from core.domain import certificate_assessment_services
from core.domain import certificate_assessment_services, topic_fetchers

from typing import Any, Dict, List, TypedDict


def _format_utc_datetime(value: datetime.datetime) -> str:
"""Formats a naive UTC datetime as an ISO-8601 string with a 'Z' suffix.

Args:
value: datetime.datetime. The naive UTC datetime to format.

Returns:
str. The ISO-8601 string representation of the datetime.
"""
return value.isoformat() + 'Z'


class CertificateAssessmentOfferingTopicDict(TypedDict):
"""Dict representation of a certificate assessment topic."""

Expand Down Expand Up @@ -536,62 +550,102 @@ class CertificateAssessmentResultHandler(
CertificateAssessmentResultHandlerNormalizedRequestDict,
]
):
"""Stub handler for fetching a certificate assessment result."""
"""Handler for fetching a certificate assessment result."""

GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON
URL_PATH_ARGS_SCHEMAS = {
'attempt_id': {'schema': {'type': 'basestring'}},
}
HANDLER_ARGS_SCHEMAS = {'GET': {}}

# TODO(#24717-2.14): Replace open_access with
# can_access_certificate_assessment_attempt once real result
# fetching logic exists.
@acl_decorators.open_access
def get(self, attempt_id: str) -> None: # pylint: disable=unused-argument
"""Returns a hardcoded result payload."""
@acl_decorators.can_access_certificate_assessment_attempt_result
def get(self, attempt_id: str) -> None:
"""Returns the result for the given attempt.

Args:
attempt_id: str. The ID of the certificate assessment attempt.
"""
attempt = certificate_assessment_services.get_certificate_attempt(
attempt_id
)
try:
certificate_offering = certificate_assessment_services.get_certificate_assessment_offering(
attempt.version_data['certificate_id']
)
except (
certificate_assessment_services.CertificateAssessmentOfferingNotFoundException
) as e:
raise self.NotFoundException(str(e)) from e
topics = topic_fetchers.get_topics_by_ids(
list(attempt.attempt_data.keys())
)
topic_names_by_id = {
topic.id: topic.name for topic in topics if topic is not None
}
# Here we use object because attempt_data values are heterogeneous
# payloads mixing strings and integers.
attempt_data: Dict[str, Dict[str, object]] = {}
for topic_id, topic_stats in attempt.attempt_data.items():
attempt_data[topic_id] = {
'topic_name': topic_names_by_id.get(topic_id, topic_id),
'total_related_questions': topic_stats[
'total_related_questions'
],
'total_correct_questions': topic_stats[
'total_correct_questions'
],
}
self.render_json(
{
'title': 'Everyday Arithmetic & Number Confidence',
'total_score': 80,
'attempt_data': {
'dummy_topic_id': {
'total_related_questions': 5,
'total_correct_questions': 4,
},
},
'is_submitted': True,
'certificate_id': certificate_offering.certificate_id,
'title': certificate_offering.title,
'total_score': attempt.total_score,
'time_taken_in_minutes': attempt.get_time_taken_in_minutes(),
'attempt_data': attempt_data,
'is_submitted': attempt.is_submitted,
}
)


class CertificateAssessmentAttemptsHandler(
base.BaseHandler[Dict[str, str], Dict[str, str]]
):
"""Stub handler for listing a learner's certificate attempts."""
"""Handler for listing a learner's certificate attempts."""

GET_HANDLER_ERROR_RETURN_TYPE = feconf.HANDLER_TYPE_JSON
URL_PATH_ARGS_SCHEMAS: Dict[str, str] = {}
HANDLER_ARGS_SCHEMAS = {'GET': {}}

# TODO(#24717-2.14): Replace open_access with
# require_user_id_else_redirect_to_homepage once learner_id is
# pulled from the session for the real implementation.
@acl_decorators.open_access
@acl_decorators.require_user_id_else_redirect_to_homepage
def get(self) -> None:
"""Returns a hardcoded list of attempts."""
self.render_json(
{
'attempts': [
{
'attempt_id': 'dummy_attempt_id',
'classroom_id': 'dummy_classroom_id',
'title': 'Everyday Arithmetic & Number Confidence',
'total_score': 80,
'attempt_index': 1,
'started_at': '2026-07-18T00:00:00Z',
'is_submitted': True,
}
]
}
"""Returns the learner's certificate assessment attempts."""
assert self.user_id is not None
attempts = certificate_assessment_services.get_certificate_attempts(
self.user_id
)
certificate_ids = list(
{attempt.version_data['certificate_id'] for attempt in attempts}
)
offerings_by_id = certificate_assessment_services.get_certificate_assessment_offerings_by_ids(
certificate_ids
)
# Here we use object because the attempt summary values are
# heterogeneous JSON payloads (strings, floats, integers and booleans).
attempt_summaries: List[Dict[str, object]] = []
for attempt in attempts:
certificate_id = attempt.version_data['certificate_id']
if certificate_id not in offerings_by_id:
continue
certificate_offering = offerings_by_id[certificate_id]
attempt_summaries.append(
{
'attempt_id': attempt.attempt_id,
'classroom_id': certificate_offering.classroom_id,
'title': certificate_offering.title,
'total_score': attempt.total_score,
'attempt_index': attempt.attempt_index,
'started_at': _format_utc_datetime(attempt.started_at),
'is_submitted': attempt.is_submitted,
}
)
self.render_json({'attempts': attempt_summaries})
Loading