Skip to content
Open
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
11 changes: 6 additions & 5 deletions core/domain/question_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -726,11 +726,12 @@ def apply_change_list(
question.update_inapplicable_skill_misconception_ids(
update_skill_misconception_ids_cmd.new_value
)
elif (
change.property_name
== question_domain.QUESTION_PROPERTY_NEXT_CONTENT_ID_INDEX
):
# Here we use cast because this 'if' condition forces
else:
assert (
change.property_name
== question_domain.QUESTION_PROPERTY_NEXT_CONTENT_ID_INDEX
)
# Here we use cast because this 'else' branch forces
# change to have type
# UpdateQuestionPropertyNextContentIdIndexCmd.
cmd = cast(
Expand Down
40 changes: 40 additions & 0 deletions core/domain/question_services_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,6 +709,46 @@ def test_delete_question_model_with_deleted_summary_model(self) -> None:
None,
)

def test_delete_question_when_question_model_does_not_exist(
self,
) -> None:
# Hard-delete the underlying model so get_by_id returns None,
# covering the `if question_model is not None` False branch.
question_models.QuestionModel.delete_multi(
[self.question_id],
self.editor_id,
feconf.COMMIT_MESSAGE_QUESTION_DELETED,
force_deletion=True,
)
self.assertIsNone(
question_models.QuestionModel.get_by_id(self.question_id)
)
question_services.delete_question(
self.editor_id, self.question_id, force_deletion=True
)
self.assertIsNone(
question_models.QuestionModel.get_by_id(self.question_id)
)

def test_apply_change_list_with_non_update_property_cmd(self) -> None:
# A change with cmd != CMD_UPDATE_QUESTION_PROPERTY should be
# silently skipped, covering the False branch of that `if`.
change_list = [
question_domain.QuestionChange(
{
'cmd': (
question_domain.CMD_MIGRATE_STATE_SCHEMA_TO_LATEST_VERSION
),
'from_version': 44,
'to_version': 45,
}
)
]
question = question_services.apply_change_list(
self.question_id, change_list
)
self.assertEqual(question.id, self.question_id)

Comment on lines +733 to +751

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Assertion is too weak for “unchanged question” behavior.

At Line 750, checking only id will pass even if other fields were mutated. Capture pre-state and assert full equality after applying the change list.

🔍 Suggested assertion upgrade
 def test_apply_change_list_with_non_update_property_cmd(self) -> None:
+    original_question = question_services.get_question_by_id(self.question_id)
+    original_question_dict = original_question.to_dict()
     # A change with cmd != CMD_UPDATE_QUESTION_PROPERTY should be
     # silently skipped, covering the False branch of that `if`.
     change_list = [
         question_domain.QuestionChange(
             {
                 'cmd': (
                     question_domain.CMD_MIGRATE_STATE_SCHEMA_TO_LATEST_VERSION
                 ),
                 'from_version': 44,
                 'to_version': 45,
             }
         )
     ]
     question = question_services.apply_change_list(
         self.question_id, change_list
     )
-    self.assertEqual(question.id, self.question_id)
+    self.assertEqual(question.to_dict(), original_question_dict)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_apply_change_list_with_non_update_property_cmd(self) -> None:
# A change with cmd != CMD_UPDATE_QUESTION_PROPERTY should be
# silently skipped, covering the False branch of that `if`.
change_list = [
question_domain.QuestionChange(
{
'cmd': (
question_domain.CMD_MIGRATE_STATE_SCHEMA_TO_LATEST_VERSION
),
'from_version': 44,
'to_version': 45,
}
)
]
question = question_services.apply_change_list(
self.question_id, change_list
)
self.assertEqual(question.id, self.question_id)
def test_apply_change_list_with_non_update_property_cmd(self) -> None:
original_question = question_services.get_question_by_id(self.question_id)
original_question_dict = original_question.to_dict()
# A change with cmd != CMD_UPDATE_QUESTION_PROPERTY should be
# silently skipped, covering the False branch of that `if`.
change_list = [
question_domain.QuestionChange(
{
'cmd': (
question_domain.CMD_MIGRATE_STATE_SCHEMA_TO_LATEST_VERSION
),
'from_version': 44,
'to_version': 45,
}
)
]
question = question_services.apply_change_list(
self.question_id, change_list
)
self.assertEqual(question.to_dict(), original_question_dict)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/domain/question_services_test.py` around lines 733 - 751, The test
test_apply_change_list_with_non_update_property_cmd only asserts question.id
after apply_change_list so mutations to other fields would go unnoticed; before
calling question_services.apply_change_list capture the full pre-state of the
question (e.g., deep copy or serialize the Question object returned by fetching
via self.question_id), then call
question_services.apply_change_list(self.question_id, change_list) and assert
the returned/loaded Question equals the captured pre-state (compare the whole
object or all relevant fields), referencing QuestionChange and
question_domain.CMD_MIGRATE_STATE_SCHEMA_TO_LATEST_VERSION to locate the change
creation and apply_change_list to locate where to compare.

def test_update_question(self) -> None:
new_question_data = self._create_valid_question_data(
'DEF', self.content_id_generator
Expand Down
15 changes: 10 additions & 5 deletions core/domain/rights_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@ def _save_activity_rights(
committer_id, activity_rights, commit_message, commit_cmds
)
return
elif activity_type == constants.ACTIVITY_TYPE_COLLECTION:
else:
assert activity_type == constants.ACTIVITY_TYPE_COLLECTION
Comment on lines +167 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and read the file
find . -name "rights_manager.py" -type f | head -5

Repository: jayam04/oppia

Length of output: 89


🏁 Script executed:

# Read the specific section around line 167
sed -n '155,185p' core/domain/rights_manager.py | cat -n

Repository: jayam04/oppia

Length of output: 1468


🏁 Script executed:

# Also check the other mentioned line ranges
echo "=== Lines 275-290 ===" && sed -n '275,290p' core/domain/rights_manager.py | cat -n
echo "=== Lines 418-430 ===" && sed -n '418,430p' core/domain/rights_manager.py | cat -n
echo "=== Lines 1156-1170 ===" && sed -n '1156,1170p' core/domain/rights_manager.py | cat -n
echo "=== Lines 1384-1400 ===" && sed -n '1384,1400p' core/domain/rights_manager.py | cat -n

Repository: jayam04/oppia

Length of output: 2875


🏁 Script executed:

# Check the function signature and broader context around _save_activity_rights
sed -n '150,200p' core/domain/rights_manager.py | cat -n

Repository: jayam04/oppia

Length of output: 2351


Remove unnecessary else after return at line 167.

The if branch returns at line 166, making the else block and its indentation unnecessary. This violates PEP 8's principle of avoiding unnecessary nesting and triggers the RET505 lint warning.

Proposed fix
     if activity_type == constants.ACTIVITY_TYPE_EXPLORATION:
         assert isinstance(activity_rights, exp_rights_domain.ExplorationRights)
         _save_exploration_rights(
             committer_id, activity_rights, commit_message, commit_cmds
         )
         return
-    else:
-        assert activity_type == constants.ACTIVITY_TYPE_COLLECTION
-        model = collection_models.CollectionRightsModel.get(
-            activity_rights.id, strict=True
-        )
+    assert activity_type == constants.ACTIVITY_TYPE_COLLECTION
+    model = collection_models.CollectionRightsModel.get(
+        activity_rights.id, strict=True
+    )

Per PEP 8: avoid unnecessary nesting and indentation after control flow statements that terminate execution.

🧰 Tools
🪛 Ruff (0.15.11)

[warning] 167-167: Unnecessary else after return statement

Remove unnecessary else

(RET505)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/domain/rights_manager.py` around lines 167 - 168, The else after the
return is unnecessary; in the function/method in rights_manager.py where you
check activity_type and return on the if branch, remove the else block and
unindent the assertion so that assert activity_type ==
constants.ACTIVITY_TYPE_COLLECTION follows directly after the return-path,
eliminating extra nesting (i.e., delete the "else:" and place the assert at the
same level as the preceding if).

model = collection_models.CollectionRightsModel.get(
activity_rights.id, strict=True
)
Expand Down Expand Up @@ -276,7 +277,8 @@ def _update_activity_summary(
if activity_type == constants.ACTIVITY_TYPE_EXPLORATION:
assert isinstance(activity_rights, exp_rights_domain.ExplorationRights)
_update_exploration_summary(activity_rights)
elif activity_type == constants.ACTIVITY_TYPE_COLLECTION:
else:
assert activity_type == constants.ACTIVITY_TYPE_COLLECTION
_update_collection_summary(activity_rights)
Comment on lines +280 to 282

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1) Check whether optimized Python mode might be used anywhere.
rg -n --iglob '*.yml' --iglob '*.yaml' --iglob '*.sh' --iglob '*.py' \
  'PYTHONOPTIMIZE|python\s+-O|python3\s+-O'

# 2) Enumerate callsites of the affected helpers.
rg -nP '\b(_update_activity_summary|_change_activity_status)\s*\('

Repository: jayam04/oppia

Length of output: 697


🏁 Script executed:

sed -n '275,290p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 633


🏁 Script executed:

sed -n '1385,1400p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 769


🏁 Script executed:

sed -n '260,285p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 987


🏁 Script executed:

sed -n '1358,1375p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 620


🏁 Script executed:

# Check all callsites of _update_activity_summary
sed -n '1195,1210p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 457


🏁 Script executed:

# Check callsite around line 1300
sed -n '1295,1310p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 538


🏁 Script executed:

# Check callsite around line 1357
sed -n '1352,1365p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 406


🏁 Script executed:

# Check callsites of _change_activity_status
sed -n '1437,1450p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 463


🏁 Script executed:

# Check the other _change_activity_status callsite
sed -n '1475,1488p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 544


🏁 Script executed:

# Find the publish_activity and unpublish_activity function signatures
rg -n 'def (publish_activity|unpublish_activity)' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 39


🏁 Script executed:

# Look at the beginning of publish_activity to see if activity_type is validated
sed -n '1410,1450p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 1461


🏁 Script executed:

# Look at unpublish_activity
sed -n '1460,1500p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 1428


🏁 Script executed:

# Find _get_activity_rights to see if it validates activity_type
rg -n 'def _get_activity_rights' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 227


🏁 Script executed:

# Read _get_activity_rights implementation
sed -n '220,260p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 1271


🏁 Script executed:

# Look for any other functions that accept activity_type and might have it unvalidated
rg -n 'activity_type:' core/domain/rights_manager.py | head -30

Repository: jayam04/oppia

Length of output: 1613


🏁 Script executed:

# Read _get_activity_rights implementation
sed -n '656,700p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 1795


🏁 Script executed:

# Check if any validation happens in calling functions - look at who calls _publish_activity/_unpublish_activity
rg -n '(publish_activity|unpublish_activity|_assign_role)\(' core/domain/rights_manager.py | head -15

Repository: jayam04/oppia

Length of output: 595


🏁 Script executed:

# Check the public API entry points like assign_role_for_exploration
sed -n '1500,1550p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 1580


🏁 Script executed:

# Check _deassign_role implementation
sed -n '1203,1280p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 3096


🏁 Script executed:

# Check _release_ownership_of_activity implementation
sed -n '1305,1360p' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 1923


🏁 Script executed:

# Verify the call sequence - check if _get_activity_rights is always called before _update_activity_summary
grep -B 10 '_update_activity_summary' core/domain/rights_manager.py | grep -E '(_get_activity_rights|_update_activity_summary)' | head -20

Repository: jayam04/oppia

Length of output: 331


🏁 Script executed:

# Double-check the exception handling in _get_activity_rights to confirm it raises for invalid types
sed -n '656,690p' core/domain/rights_manager.py | grep -A 20 'else:'

Repository: jayam04/oppia

Length of output: 231


🏁 Script executed:

# Verify if _update_activity_summary and _change_activity_status can be called directly
# Check if they are marked as private (leading underscore) and search for direct calls without _get_activity_rights
rg -n '_update_activity_summary|_change_activity_status' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 457


🏁 Script executed:

# Check if there are any public API functions that might call these directly
rg -n '^def [a-z].*activity_type' core/domain/rights_manager.py

Repository: jayam04/oppia

Length of output: 39


Replace assert discriminator checks with explicit exception handling.

At lines 281 and 1390, the code uses assert statements as fallback checks for invalid activity_type values. While _get_activity_rights() does validate activity_type upstream, defensive programming practices recommend replacing these asserts with explicit exceptions. This makes the code more robust and documents the intent clearly, especially since assert statements could theoretically be disabled (even though PYTHONOPTIMIZE is not currently used in the repository).

Consider the suggested fix to replace both else: assert activity_type == ... blocks with elif activity_type == ...: ... else: raise Exception(...) patterns for clarity and safety.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/domain/rights_manager.py` around lines 280 - 282, Replace the fallback
assert checks for activity_type with explicit branches and an error raise: in
the block that currently does "else: assert activity_type ==
constants.ACTIVITY_TYPE_COLLECTION; _update_collection_summary(activity_rights)"
change the control flow to check "elif activity_type ==
constants.ACTIVITY_TYPE_COLLECTION: _update_collection_summary(activity_rights)"
and add a final "else: raise ValueError(f'Unexpected activity_type:
{activity_type}')" (do the same change for the other similar assert usage). This
ensures _update_collection_summary and related code only run for the expected
discriminator and throws a clear exception when activity_type is invalid.



Expand Down Expand Up @@ -418,7 +420,8 @@ def _get_activity_rights_where_user_is_owner(
exp_models.ExplorationRightsModel.owner_ids == user_id
)
).fetch()
elif activity_type == constants.ACTIVITY_TYPE_COLLECTION:
else:
assert activity_type == constants.ACTIVITY_TYPE_COLLECTION
activity_rights_models = collection_models.CollectionRightsModel.query(
datastore_services.any_of(
collection_models.CollectionRightsModel.owner_ids == user_id
Expand Down Expand Up @@ -1155,7 +1158,8 @@ def _assign_role(
activity_rights.viewer_ids.remove(assignee_id)
old_role = rights_domain.ROLE_VIEWER

elif new_role == rights_domain.ROLE_VIEWER:
else:
assert new_role == rights_domain.ROLE_VIEWER

if (
activity_rights.is_owner(assignee_id)
Expand Down Expand Up @@ -1382,7 +1386,8 @@ def _change_activity_status(
activity_rights.status = new_status
if activity_type == constants.ACTIVITY_TYPE_EXPLORATION:
cmd_type = rights_domain.CMD_CHANGE_EXPLORATION_STATUS
elif activity_type == constants.ACTIVITY_TYPE_COLLECTION:
else:
assert activity_type == constants.ACTIVITY_TYPE_COLLECTION
cmd_type = rights_domain.CMD_CHANGE_COLLECTION_STATUS
commit_cmds = [
{'cmd': cmd_type, 'old_status': old_status, 'new_status': new_status}
Expand Down
209 changes: 209 additions & 0 deletions core/domain/rights_manager_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,19 @@ def test_check_can_modify_core_activity_roles_for_none_activity(
)
)

def test_check_can_modify_core_activity_roles_owned_action_but_not_owner(
self,
) -> None:
# user_a has ACTION_MODIFY_CORE_ROLES_FOR_OWNED_ACTIVITY but is not
# the owner of exp_id, so the function should return False.
self.save_new_valid_exploration(self.EXP_ID, self.user_id_b)
exp_rights = rights_manager.get_exploration_rights(self.EXP_ID)
self.assertFalse(
rights_manager.check_can_modify_core_activity_roles(
self.user_a, exp_rights
)
)
Comment on lines +221 to +232

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Make this non-owner branch test independent of default role permissions.

At Line 229, this uses self.user_a, which ties branch intent to current default action mappings. Prefer an explicit mock/user object with ACTION_MODIFY_CORE_ROLES_FOR_OWNED_ACTIVITY and a non-owner user_id so this remains stable if role policies change.

✅ Deterministic test setup
     def test_check_can_modify_core_activity_roles_owned_action_but_not_owner(
         self,
     ) -> None:
-        # user_a has ACTION_MODIFY_CORE_ROLES_FOR_OWNED_ACTIVITY but is not
-        # the owner of exp_id, so the function should return False.
         self.save_new_valid_exploration(self.EXP_ID, self.user_id_b)
         exp_rights = rights_manager.get_exploration_rights(self.EXP_ID)
+        mock_user = unittest.mock.MagicMock(spec=user_domain.UserActionsInfo)
+        mock_user.user_id = self.user_id_a
+        mock_user.actions = [
+            role_services.ACTION_MODIFY_CORE_ROLES_FOR_OWNED_ACTIVITY
+        ]
         self.assertFalse(
             rights_manager.check_can_modify_core_activity_roles(
-                self.user_a, exp_rights
+                mock_user, exp_rights
             )
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/domain/rights_manager_test.py` around lines 221 - 232, The test
test_check_can_modify_core_activity_roles_owned_action_but_not_owner relies on
self.user_a's default role permissions; instead create a dedicated test user
object or mock (e.g., local_user = UserActionsMock(...) or a new user_id and
user object) that explicitly has ACTION_MODIFY_CORE_ROLES_FOR_OWNED_ACTIVITY but
is not the owner of EXP_ID, then pass that explicit non-owner user into
rights_manager.check_can_modify_core_activity_roles (replace self.user_a usage)
so the test no longer depends on global/default role mappings; ensure the
created user_id differs from the exploration owner (self.user_id_b) and that the
mock/user exposes the same interface used by
check_can_modify_core_activity_roles.


def test_non_splash_page_demo_exploration(self) -> None:
# Note: there is no difference between permissions for demo
# explorations, whether or not they are on the splash page.
Expand Down Expand Up @@ -1287,6 +1300,188 @@ def test_get_activity_rights_where_user_is_owner_for_exploration(
self.assertEqual(activity_rights_list[0].id, 'exp1')
self.assertTrue(activity_rights_list[0].is_owner(owner_id))

def test_get_exploration_rights_where_user_is_owner(self) -> None:
self.save_new_valid_exploration(self.EXP_ID, self.user_id_a)
owned = rights_manager.get_exploration_rights_where_user_is_owner(
self.user_id_a
)
self.assertEqual(len(owned), 1)
self.assertEqual(owned[0].id, self.EXP_ID)

def test_exploration_status_helpers(self) -> None:
exp = exp_domain.Exploration.create_default_exploration(
self.EXP_ID, title='A title', category='A category'
)
exp_services.save_new_exploration(self.user_id_a, exp)
self.assertTrue(rights_manager.is_exploration_private(self.EXP_ID))
self.assertFalse(rights_manager.is_exploration_public(self.EXP_ID))
self.assertFalse(rights_manager.is_exploration_cloned(self.EXP_ID))
rights_manager.publish_exploration(self.user_a, self.EXP_ID)
self.assertFalse(rights_manager.is_exploration_private(self.EXP_ID))
self.assertTrue(rights_manager.is_exploration_public(self.EXP_ID))

def test_check_can_functions_return_false_for_none_activity_rights(
self,
) -> None:
self.assertFalse(
rights_manager.check_can_access_activity(self.user_a, None)
)
self.assertFalse(
rights_manager.check_can_edit_activity(self.user_a, None)
)
self.assertFalse(
rights_manager.check_can_voiceover_activity(self.user_a, None)
)
self.assertFalse(
rights_manager.check_can_delete_activity(self.user_a, None)
)
self.assertFalse(
rights_manager.check_can_release_ownership(self.user_a, None)
)
self.assertFalse(
rights_manager.check_can_publish_activity(self.user_a, None)
)
self.assertFalse(
rights_manager.check_can_unpublish_activity(self.user_a, None)
)

def test_check_can_edit_activity_returns_false_without_edit_action(
self,
) -> None:
guest_user = user_services.get_user_actions_info(None)
self.save_new_valid_exploration(self.EXP_ID, self.user_id_a)
exp_rights = rights_manager.get_exploration_rights(self.EXP_ID)
self.assertFalse(
rights_manager.check_can_edit_activity(guest_user, exp_rights)
)

def test_check_can_voiceover_activity_returns_false_without_edit_action(
self,
) -> None:
guest_user = user_services.get_user_actions_info(None)
self.save_new_valid_exploration(self.EXP_ID, self.user_id_a)
exp_rights = rights_manager.get_exploration_rights(self.EXP_ID)
self.assertFalse(
rights_manager.check_can_voiceover_activity(guest_user, exp_rights)
)

def test_check_can_modify_core_roles_returns_false_for_community_owned(
self,
) -> None:
exp = exp_domain.Exploration.create_default_exploration(
self.EXP_ID, title='A title', category='A category'
)
exp_services.save_new_exploration(self.user_id_a, exp)
rights_manager.publish_exploration(self.user_a, self.EXP_ID)
rights_manager.release_ownership_of_exploration(
self.user_a, self.EXP_ID
)
exp_rights = rights_manager.get_exploration_rights(self.EXP_ID)
self.assertFalse(
rights_manager.check_can_modify_core_activity_roles(
self.user_a, exp_rights
)
)

def test_check_can_modify_core_roles_returns_false_without_owned_action(
self,
) -> None:
# Use a mock user with no actions to force the outer `if` at the
# ACTION_MODIFY_CORE_ROLES_FOR_OWNED_ACTIVITY check to be False.
mock_user = unittest.mock.MagicMock(spec=user_domain.UserActionsInfo)
mock_user.user_id = self.user_id_a
mock_user.actions = []
self.save_new_valid_exploration(self.EXP_ID, self.user_id_a)
exp_rights = rights_manager.get_exploration_rights(self.EXP_ID)
self.assertFalse(
rights_manager.check_can_modify_core_activity_roles(
mock_user, exp_rights
)
)

def test_check_can_publish_returns_false_for_cloned_activity(
self,
) -> None:
mock_rights = unittest.mock.MagicMock()
mock_rights.cloned_from = 'some_exploration_id'
self.assertFalse(
rights_manager.check_can_publish_activity(self.user_a, mock_rights)
)

def test_check_can_publish_returns_false_without_publish_action(
self,
) -> None:
# Use a mock user with no actions to force the outer `if` at the
# ACTION_PUBLISH_OWNED_ACTIVITY check to be False.
mock_user = unittest.mock.MagicMock(spec=user_domain.UserActionsInfo)
mock_user.user_id = self.user_id_b
mock_user.actions = []
self.save_new_valid_exploration(self.EXP_ID, self.user_id_a)
exp_rights = rights_manager.get_exploration_rights(self.EXP_ID)
self.assertFalse(
rights_manager.check_can_publish_activity(mock_user, exp_rights)
)

def test_check_can_unpublish_returns_false_for_community_owned(
self,
) -> None:
exp = exp_domain.Exploration.create_default_exploration(
self.EXP_ID, title='A title', category='A category'
)
exp_services.save_new_exploration(self.user_id_a, exp)
rights_manager.publish_exploration(self.user_a, self.EXP_ID)
rights_manager.release_ownership_of_exploration(
self.user_a, self.EXP_ID
)
exp_rights = rights_manager.get_exploration_rights(self.EXP_ID)
self.assertFalse(
rights_manager.check_can_unpublish_activity(
self.user_moderator, exp_rights
)
)

def test_release_ownership_raises_for_unauthorized_user(self) -> None:
self.save_new_valid_exploration(self.EXP_ID, self.user_id_a)
rights_manager.publish_exploration(self.user_a, self.EXP_ID)
with self.assertRaisesRegex(
Exception,
'The ownership of this exploration cannot be released.',
):
rights_manager.release_ownership_of_exploration(
self.user_b, self.EXP_ID
)

def test_publish_raises_for_unauthorized_user(self) -> None:
self.save_new_valid_exploration(self.EXP_ID, self.user_id_a)
with self.assertRaisesRegex(
Exception, 'This exploration cannot be published.'
):
rights_manager.publish_exploration(self.user_b, self.EXP_ID)

def test_unpublish_raises_for_unauthorized_user(self) -> None:
self.save_new_valid_exploration(self.EXP_ID, self.user_id_a)
rights_manager.publish_exploration(self.user_a, self.EXP_ID)
with self.assertRaisesRegex(
Exception, 'This exploration cannot be unpublished.'
):
rights_manager.unpublish_exploration(self.user_a, self.EXP_ID)

def test_republish_does_not_reset_first_published_msec(self) -> None:
self.save_new_valid_exploration(self.EXP_ID, self.user_id_a)
rights_manager.publish_exploration(self.user_a, self.EXP_ID)
rights_manager.unpublish_exploration(self.user_moderator, self.EXP_ID)
first_published = rights_manager.get_exploration_rights(
self.EXP_ID
).first_published_msec
self.assertIsNotNone(first_published)
rights_manager.publish_exploration(self.user_a, self.EXP_ID)
self.assertEqual(
rights_manager.get_exploration_rights(
self.EXP_ID
).first_published_msec,
first_published,
)


class CollectionRightsTests(test_utils.GenericTestBase):
"""Test that rights for actions on collections work as expected."""
Expand Down Expand Up @@ -2132,6 +2327,20 @@ def test_get_collection_rights_where_user_is_owner(self) -> None:
self.assertEqual(owned_rights[0].id, 'col1')
self.assertTrue(owned_rights[0].is_owner(self.user_id_a))

def test_collection_status_helpers(self) -> None:
self.save_new_valid_collection(self.COLLECTION_ID, self.user_id_a)
self.assertTrue(
rights_manager.is_collection_private(self.COLLECTION_ID)
)
self.assertFalse(
rights_manager.is_collection_public(self.COLLECTION_ID)
)
rights_manager.publish_collection(self.user_a, self.COLLECTION_ID)
self.assertFalse(
rights_manager.is_collection_private(self.COLLECTION_ID)
)
self.assertTrue(rights_manager.is_collection_public(self.COLLECTION_ID))


class CheckCanReleaseOwnershipTest(test_utils.GenericTestBase):
"""Tests for check_can_release_ownership function."""
Expand Down
Loading
Loading