diff --git a/games/constants.py b/games/constants.py index 1a9bc24..466457d 100644 --- a/games/constants.py +++ b/games/constants.py @@ -8,6 +8,7 @@ class GAME_TYPE: """Game type constants.""" + FLASHCARDS = "flashcards" MATCHING = "matching" VALID = [FLASHCARDS, MATCHING] @@ -15,6 +16,7 @@ class GAME_TYPE: class DEFAULT: """Default values for XBlock fields.""" + TITLE = "Matching" DISPLAY_NAME = "Games" GAME_TYPE = GAME_TYPE.MATCHING @@ -26,6 +28,7 @@ class DEFAULT: class CARD_FIELD: """Card field names.""" + TERM = "term" TERM_IMAGE = "term_image" DEFINITION = "definition" @@ -35,17 +38,19 @@ class CARD_FIELD: class CONTAINER_TYPE: """Container types for matching game.""" + TERM = "term" DEFINITION = "definition" class UPLOAD: """File upload settings.""" + PATH_PREFIX = "games" - DEFAULT_EXTENSION = "jpg" class CONFIG: """Configuration values.""" + RANDOM_STRING_LENGTH = 6 MATCHES_PER_PAGE = 5 # Number of matches displayed per page diff --git a/games/games.py b/games/games.py index 9687a83..bea8f3f 100644 --- a/games/games.py +++ b/games/games.py @@ -9,6 +9,7 @@ from .constants import DEFAULT from .handlers import CommonHandlers, FlashcardsHandlers, MatchingHandlers + class GamesXBlock(XBlock): """ An XBlock for creating games. @@ -25,14 +26,18 @@ class GamesXBlock(XBlock): help=_("The title of the block to be displayed in the xblock."), ) display_name = String( - default=DEFAULT.DISPLAY_NAME, scope=Scope.settings, help=_("Display name for this XBlock") + default=DEFAULT.DISPLAY_NAME, + scope=Scope.settings, + help="Display name for this XBlock", ) # Change default to 'matching' for matching game and 'flashcards' for flashcards game to test game_type = String( default=DEFAULT.GAME_TYPE, scope=Scope.settings, - help=_("The kind of game this xblock is responsible for ('flashcards' or 'matching' for now)."), + help=_( + "The kind of game this xblock is responsible for ('flashcards' or 'matching' for now)." + ), ) cards = List( @@ -64,19 +69,25 @@ class GamesXBlock(XBlock): matching_id_dictionary_index = Dict( default={}, scope=Scope.user_state, - help=_("A dictionary to encrypt the ids of the terms and definitions for the matching game."), + help=_( + "A dictionary to encrypt the ids of the terms and definitions for the matching game." + ), ) matching_id_dictionary_type = Dict( default={}, scope=Scope.user_state, - help=_("A dictionary to tie the id to the type of container (term or definition) for the matching game."), + help=_( + "A dictionary to tie the id to the type of container (term or definition) for the matching game." + ), ) matching_id_dictionary = Dict( default={}, scope=Scope.user_state, - help=_("A dictionary to encrypt the ids of the terms and definitions for the matching game."), + help=_( + "A dictionary to encrypt the ids of the terms and definitions for the matching game." + ), ) matching_id_list = List( @@ -106,7 +117,9 @@ class GamesXBlock(XBlock): match_count = Integer( default=DEFAULT.MATCH_COUNT, scope=Scope.user_state, - help=_("Tracks how many matches have been successfully made. Used to determine when to switch pages."), + help=_( + "Tracks how many matches have been successfully made. Used to determine when to switch pages." + ), ) matches_remaining = Integer( @@ -116,7 +129,9 @@ class GamesXBlock(XBlock): selected_containers = Dict( default={}, scope=Scope.user_state, - help=_("A dictionary to keep track of selected containers for the matching game."), + help=_( + "A dictionary to keep track of selected containers for the matching game." + ), ) is_shuffled = Boolean( @@ -153,7 +168,7 @@ def student_view(self, context=None): # Common handlers------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ @XBlock.json_handler - def expand_game(self, data, suffix=''): + def expand_game(self, data, suffix=""): """A handler to expand the game from its title block.""" return CommonHandlers.expand_game(self, data, suffix) @@ -164,58 +179,69 @@ def get_settings(self, data, suffix=""): @XBlock.handler def upload_image(self, request, suffix=""): - """Upload an image file and return the URL.""" + """ + Upload an image file to configured storage (S3 if set) and return URL. + """ return CommonHandlers.upload_image(self, request, suffix) + @XBlock.json_handler + def delete_image_handler(self, data, suffix=""): + """ + Delete an image by storage key. + Expected: { "key": "gamesxblock//.ext" } + """ + # TODO: Delete API is not integrated yet, will handle this one after API is integrated if any change needed. + return CommonHandlers.delete_image(self, data, suffix) + @XBlock.json_handler def save_settings(self, data, suffix=""): """Save game type, shuffle setting, and all cards in one API call.""" return CommonHandlers.save_settings(self, data, suffix) @XBlock.json_handler - def close_game(self, data, suffix=''): + def close_game(self, data, suffix=""): """A handler to close the game to its title block.""" return CommonHandlers.close_game(self, data, suffix) @XBlock.json_handler - def display_help(self, data, suffix=''): + def display_help(self, data, suffix=""): """A handler to display a tooltip message above the help icon.""" return CommonHandlers.display_help(self, data, suffix) # Flashcards handlers------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ @XBlock.json_handler - def start_game_flashcards(self, data, suffix=''): + def start_game_flashcards(self, data, suffix=""): """A handler to begin the flashcards game.""" return FlashcardsHandlers.start_game_flashcards(self, data, suffix) @XBlock.json_handler - def flip_flashcard(self, data, suffix=''): + def flip_flashcard(self, data, suffix=""): """A handler to flip the flashcard from term to definition and vice versa.""" return FlashcardsHandlers.flip_flashcard(self, data, suffix) @XBlock.json_handler - def page_turn(self, data, suffix=''): + def page_turn(self, data, suffix=""): """A handler to turn the page to a new flashcard (left or right) in the list.""" return FlashcardsHandlers.page_turn(self, data, suffix) # Matching handlers------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ @XBlock.json_handler - def start_game_matching(self, data, suffix=''): + def start_game_matching(self, data, suffix=""): """A handler to begin the matching game.""" return MatchingHandlers.start_game_matching(self, data, suffix) @XBlock.json_handler - def update_timer(self, data, suffix=''): + def update_timer(self, data, suffix=""): """Update the timer. This is called every 1000ms by an ajax call.""" return MatchingHandlers.update_timer(self, data, suffix) @XBlock.json_handler - def select_container(self, data, suffix=''): + def select_container(self, data, suffix=""): """A handler for selecting matching game containers and evaluating matches.""" return MatchingHandlers.select_container(self, data, suffix) @XBlock.json_handler - def end_game_matching(self, data, suffix=''): + def end_game_matching(self, data, suffix=""): """End the matching game and compare the user's time to the best_time field.""" return MatchingHandlers.end_game_matching(self, data, suffix) diff --git a/games/handlers/__init__.py b/games/handlers/__init__.py index 99b5fca..acda9c4 100644 --- a/games/handlers/__init__.py +++ b/games/handlers/__init__.py @@ -11,4 +11,4 @@ from .flashcards import FlashcardsHandlers from .matching import MatchingHandlers -__all__ = ['CommonHandlers', 'FlashcardsHandlers', 'MatchingHandlers'] +__all__ = ["CommonHandlers", "FlashcardsHandlers", "MatchingHandlers"] diff --git a/games/handlers/common.py b/games/handlers/common.py index e9a03f0..ae40ca9 100644 --- a/games/handlers/common.py +++ b/games/handlers/common.py @@ -3,6 +3,7 @@ This module contains handlers that work across all game types. """ + import hashlib from django.core.files.base import ContentFile @@ -16,13 +17,14 @@ GAME_TYPE, UPLOAD, ) +from games.utils import get_gamesxblock_storage, delete_image class CommonHandlers: """Handlers that work across all game types.""" @staticmethod - def expand_game(xblock, data, suffix=''): + def expand_game(xblock, data, suffix=""): """A handler to expand the game from its title block.""" description = _("ERR: self.game_type not defined or invalid") if xblock.game_type == GAME_TYPE.FLASHCARDS: @@ -30,9 +32,9 @@ def expand_game(xblock, data, suffix=''): elif xblock.game_type == GAME_TYPE.MATCHING: description = _("Match each term with the correct definition") return { - 'title': xblock.title, - 'description': description, - 'game_type': xblock.game_type + "title": xblock.title, + "description": description, + "game_type": xblock.game_type, } @staticmethod @@ -46,26 +48,63 @@ def get_settings(xblock, data, suffix=""): @staticmethod def upload_image(xblock, request, suffix=""): - """Upload an image file and return the URL.""" + """ + Upload an image file to configured storage (S3 if set) and return URL. + """ + asset_storage = get_gamesxblock_storage() try: upload_file = request.params["file"].file file_name = request.params["file"].filename - file_hash = hashlib.md5(upload_file.read()).hexdigest() - upload_file.seek(0) - _, ext = ( - file_name.rsplit(".", 1) if "." in file_name else (file_name, UPLOAD.DEFAULT_EXTENSION) - ) + if "." not in file_name: + return Response( + json_body={ + "success": False, + "error": "File must have an extension", + }, + status=400, + ) + _, ext = file_name.rsplit(".", 1) + ext = ext.lower() + allowed_exts = ["jpg", "jpeg", "png", "gif", "webp", "svg"] + if ext not in allowed_exts: + return Response( + json_body={ + "success": False, + "error": f"Unsupported file type '.{ext}'. Allowed: {', '.join(sorted(allowed_exts))}", + }, + status=400, + ) + blob = upload_file.read() + file_hash = hashlib.md5(blob).hexdigest() file_path = f"{UPLOAD.PATH_PREFIX}/{xblock.scope_ids.usage_id.block_id}/{file_hash}.{ext}" - saved_path = default_storage.save( - file_path, ContentFile(upload_file.read()) - ) - file_url = default_storage.url(saved_path) + saved_path = asset_storage.save(file_path, ContentFile(blob)) + file_url = asset_storage.url(saved_path) return Response( - json_body={"success": True, "url": file_url, "filename": file_name} + json_body={ + "success": True, + "url": file_url, + "filename": file_name, + "file_path": file_path, + } ) except Exception as e: return Response(json_body={"success": False, "error": str(e)}, status=400) + @staticmethod + def delete_image_handler(self, data, suffix=""): + """ + Delete an image by storage key. + Expected: { "key": "gamesxblock//.ext" } + """ + key = data.get("key") + if not key: + return {"success": False, "error": "Missing key"} + try: + is_deleted = delete_image(self.asset_storage, key) + return {"success": is_deleted, "key": key} + except Exception as e: + return {"success": False, "error": str(e)} + @staticmethod def save_settings(xblock, data, suffix=""): """ @@ -107,7 +146,9 @@ def save_settings(xblock, data, suffix=""): CARD_FIELD.TERM: card.get(CARD_FIELD.TERM, ""), CARD_FIELD.TERM_IMAGE: card.get(CARD_FIELD.TERM_IMAGE, ""), CARD_FIELD.DEFINITION: card.get(CARD_FIELD.DEFINITION, ""), - CARD_FIELD.DEFINITION_IMAGE: card.get(CARD_FIELD.DEFINITION_IMAGE, ""), + CARD_FIELD.DEFINITION_IMAGE: card.get( + CARD_FIELD.DEFINITION_IMAGE, "" + ), CARD_FIELD.ORDER: card.get(CARD_FIELD.ORDER, ""), } ) @@ -129,7 +170,7 @@ def save_settings(xblock, data, suffix=""): return {"success": False, "error": str(e)} @staticmethod - def close_game(xblock, data, suffix=''): + def close_game(xblock, data, suffix=""): """A handler to close the game to its title block.""" xblock.game_started = False xblock.time_seconds = 0 @@ -139,16 +180,14 @@ def close_game(xblock, data, suffix=''): if xblock.game_type == GAME_TYPE.FLASHCARDS: xblock.term_is_visible = True xblock.list_index = 0 - return { - 'title': xblock.title - } + return {"title": xblock.title} @staticmethod - def display_help(xblock, data, suffix=''): + def display_help(xblock, data, suffix=""): """A handler to display a tooltip message above the help icon.""" message = _("ERR: self.game_type not defined or invalid") if xblock.game_type == GAME_TYPE.FLASHCARDS: message = _("Click each card to reveal the definition") elif xblock.game_type == GAME_TYPE.MATCHING: message = _("Match each term with the correct definition") - return {'message': message} + return {"message": message} diff --git a/games/handlers/flashcards.py b/games/handlers/flashcards.py index 6fff367..b2df2e1 100644 --- a/games/handlers/flashcards.py +++ b/games/handlers/flashcards.py @@ -9,47 +9,47 @@ class FlashcardsHandlers: """Handlers specific to the flashcards game.""" @staticmethod - def start_game_flashcards(xblock, data, suffix=''): + def start_game_flashcards(xblock, data, suffix=""): """A handler to begin the flashcards game.""" return { - 'list': xblock.list, - 'list_index': xblock.list_index, - 'list_length': xblock.list_length + "list": xblock.list, + "list_index": xblock.list_index, + "list_length": xblock.list_length, } @staticmethod - def flip_flashcard(xblock, data, suffix=''): + def flip_flashcard(xblock, data, suffix=""): """A handler to flip the flashcard from term to definition and vice versa.""" if xblock.term_is_visible: - xblock.term_is_visible = not(xblock.term_is_visible) + xblock.term_is_visible = not (xblock.term_is_visible) return { - 'image': xblock.list[xblock.list_index]['definition_image'], - 'text': xblock.list[xblock.list_index]['definition'] + "image": xblock.list[xblock.list_index]["definition_image"], + "text": xblock.list[xblock.list_index]["definition"], } - xblock.term_is_visible = not(xblock.term_is_visible) + xblock.term_is_visible = not (xblock.term_is_visible) return { - 'image': xblock.list[xblock.list_index]['term_image'], - 'text': xblock.list[xblock.list_index]['term'] + "image": xblock.list[xblock.list_index]["term_image"], + "text": xblock.list[xblock.list_index]["term"], } @staticmethod - def page_turn(xblock, data, suffix=''): + def page_turn(xblock, data, suffix=""): """A handler to turn the page to a new flashcard (left or right) in the list.""" # Always display the term first for a new flashcard. xblock.term_is_visible = True - if data['nextIndex'] == 'left': + if data["nextIndex"] == "left": if xblock.list_index > 0: xblock.list_index -= 1 # else if the current index is 0, circulate to the last flashcard else: xblock.list_index = len(xblock.list) - 1 return { - 'term_image': xblock.list[xblock.list_index]['term_image'], - 'term': xblock.list[xblock.list_index]['term'], - 'index': xblock.list_index + 1, - 'list_length': xblock.list_length + "term_image": xblock.list[xblock.list_index]["term_image"], + "term": xblock.list[xblock.list_index]["term"], + "index": xblock.list_index + 1, + "list_length": xblock.list_length, } # else data['nextIndex'] == 'right' @@ -59,8 +59,8 @@ def page_turn(xblock, data, suffix=''): else: xblock.list_index = 0 return { - 'term_image': xblock.list[xblock.list_index]['term_image'], - 'term': xblock.list[xblock.list_index]['term'], - 'index': xblock.list_index + 1, - 'list_length': xblock.list_length + "term_image": xblock.list[xblock.list_index]["term_image"], + "term": xblock.list[xblock.list_index]["term"], + "index": xblock.list_index + 1, + "list_length": xblock.list_length, } diff --git a/games/handlers/matching.py b/games/handlers/matching.py index bd6a810..8213885 100644 --- a/games/handlers/matching.py +++ b/games/handlers/matching.py @@ -3,6 +3,7 @@ This module contains handlers specific to the matching game type. """ + import random import string @@ -15,10 +16,12 @@ class MatchingHandlers: @staticmethod def _random_string(): """Generate a random ASCII string of configured length (upper- and lower-case).""" - return str(''.join(random.choices(string.ascii_letters, k=CONFIG.RANDOM_STRING_LENGTH))) + return str( + "".join(random.choices(string.ascii_letters, k=CONFIG.RANDOM_STRING_LENGTH)) + ) @staticmethod - def start_game_matching(xblock, data, suffix=''): + def start_game_matching(xblock, data, suffix=""): """A handler to begin the matching game.""" # Set game fields accordingly xblock.game_started = True @@ -43,102 +46,145 @@ def start_game_matching(xblock, data, suffix=''): xblock.matching_id_dictionary_index[xblock.matching_id_list[i]] = i // 2 xblock.matching_id_dictionary_index[xblock.matching_id_list[i + 1]] = i // 2 - xblock.matching_id_dictionary_type[xblock.matching_id_list[i]] = CONTAINER_TYPE.TERM - xblock.matching_id_dictionary_type[xblock.matching_id_list[i + 1]] = CONTAINER_TYPE.DEFINITION + xblock.matching_id_dictionary_type[xblock.matching_id_list[i]] = ( + CONTAINER_TYPE.TERM + ) + xblock.matching_id_dictionary_type[xblock.matching_id_list[i + 1]] = ( + CONTAINER_TYPE.DEFINITION + ) - xblock.matching_id_dictionary[xblock.matching_id_list[i]] = xblock.list[i // 2]['term'] - xblock.matching_id_dictionary[xblock.matching_id_list[i + 1]] = xblock.list[i // 2]['definition'] + xblock.matching_id_dictionary[xblock.matching_id_list[i]] = xblock.list[ + i // 2 + ]["term"] + xblock.matching_id_dictionary[xblock.matching_id_list[i + 1]] = xblock.list[ + i // 2 + ]["definition"] return { - 'list': xblock.list, - 'list_index': xblock.list_index, - 'list_length': xblock.list_length, - 'id_dictionary_index': xblock.matching_id_dictionary_index, - 'id_dictionary': xblock.matching_id_dictionary, - 'id_list': xblock.matching_id_list, - 'time': "0:00" + "list": xblock.list, + "list_index": xblock.list_index, + "list_length": xblock.list_length, + "id_dictionary_index": xblock.matching_id_dictionary_index, + "id_dictionary": xblock.matching_id_dictionary, + "id_list": xblock.matching_id_list, + "time": "0:00", } @staticmethod - def update_timer(xblock, data, suffix=''): + def update_timer(xblock, data, suffix=""): """Update the timer. This is called every 1000ms by an ajax call.""" # Only increment the timer if the game has started if xblock.game_started: xblock.time_seconds += 1 - return {'value': xblock.time_seconds, 'game_started': xblock.game_started} + return {"value": xblock.time_seconds, "game_started": xblock.game_started} @staticmethod - def select_container(xblock, data, suffix=''): + def select_container(xblock, data, suffix=""): """Handler for selecting matching game containers and evaluating matches.""" # Add a '#' to id for use with jQuery - id = "#" + data['id'] - container_type = xblock.matching_id_dictionary_type[data['id']] - index = xblock.matching_id_dictionary_index[data['id']] + id = "#" + data["id"] + container_type = xblock.matching_id_dictionary_type[data["id"]] + index = xblock.matching_id_dictionary_index[data["id"]] # If no container is selected yet if len(xblock.selected_containers) == 0: - xblock.selected_containers['container1_id'] = id - xblock.selected_containers['container1_type'] = container_type - xblock.selected_containers['container1_index'] = index + xblock.selected_containers["container1_id"] = id + xblock.selected_containers["container1_type"] = container_type + xblock.selected_containers["container1_index"] = index return { - 'first_selection': True, 'deselect': False, 'id': id, 'prev_id': None, - 'match': False, 'match_count': xblock.match_count, - 'matches_remaining': xblock.matches_remaining, 'list': xblock.list, - 'list_length': xblock.list_length, 'id_list': xblock.matching_id_list, - 'id_dictionary': xblock.matching_id_dictionary, 'time_seconds': xblock.time_seconds + "first_selection": True, + "deselect": False, + "id": id, + "prev_id": None, + "match": False, + "match_count": xblock.match_count, + "matches_remaining": xblock.matches_remaining, + "list": xblock.list, + "list_length": xblock.list_length, + "id_list": xblock.matching_id_list, + "id_dictionary": xblock.matching_id_dictionary, + "time_seconds": xblock.time_seconds, } # Establish prev_id before conditionals - prev_id = xblock.selected_containers['container1_id'] + prev_id = xblock.selected_containers["container1_id"] # If the container referenced by 'id' is already selected, deselect it - if id == xblock.selected_containers['container1_id']: + if id == xblock.selected_containers["container1_id"]: xblock.selected_containers.clear() return { - 'first_selection': False, 'deselect': True, 'id': id, 'prev_id': prev_id, - 'match': False, 'match_count': xblock.match_count, - 'matches_remaining': xblock.matches_remaining, 'list': xblock.list, - 'list_length': xblock.list_length, 'id_list': xblock.matching_id_list, - 'id_dictionary': xblock.matching_id_dictionary, 'time_seconds': xblock.time_seconds + "first_selection": False, + "deselect": True, + "id": id, + "prev_id": prev_id, + "match": False, + "match_count": xblock.match_count, + "matches_remaining": xblock.matches_remaining, + "list": xblock.list, + "list_length": xblock.list_length, + "id_list": xblock.matching_id_list, + "id_dictionary": xblock.matching_id_dictionary, + "time_seconds": xblock.time_seconds, } # Containers with the same type cannot match (i.e. a term with a term, etc.) - if container_type == xblock.selected_containers['container1_type']: + if container_type == xblock.selected_containers["container1_type"]: xblock.selected_containers.clear() return { - 'first_selection': False, 'deselect': False, 'id': id, 'prev_id': prev_id, - 'match': False, 'match_count': xblock.match_count, - 'matches_remaining': xblock.matches_remaining, 'list': xblock.list, - 'list_length': xblock.list_length, 'id_list': xblock.matching_id_list, - 'id_dictionary': xblock.matching_id_dictionary, 'time_seconds': xblock.time_seconds + "first_selection": False, + "deselect": False, + "id": id, + "prev_id": prev_id, + "match": False, + "match_count": xblock.match_count, + "matches_remaining": xblock.matches_remaining, + "list": xblock.list, + "list_length": xblock.list_length, + "id_list": xblock.matching_id_list, + "id_dictionary": xblock.matching_id_dictionary, + "time_seconds": xblock.time_seconds, } # If the execution gets to this point and the indices are the same, this is a match - if index == xblock.selected_containers['container1_index']: + if index == xblock.selected_containers["container1_index"]: xblock.selected_containers.clear() xblock.match_count += 1 xblock.matches_remaining -= 1 return { - 'first_selection': False, 'deselect': False, 'id': id, 'prev_id': prev_id, - 'match': True, 'match_count': xblock.match_count, - 'matches_remaining': xblock.matches_remaining, 'list': xblock.list, - 'list_length': xblock.list_length, 'id_list': xblock.matching_id_list, - 'id_dictionary': xblock.matching_id_dictionary, 'time_seconds': xblock.time_seconds + "first_selection": False, + "deselect": False, + "id": id, + "prev_id": prev_id, + "match": True, + "match_count": xblock.match_count, + "matches_remaining": xblock.matches_remaining, + "list": xblock.list, + "list_length": xblock.list_length, + "id_list": xblock.matching_id_list, + "id_dictionary": xblock.matching_id_dictionary, + "time_seconds": xblock.time_seconds, } # Not a match xblock.selected_containers.clear() return { - 'first_selection': False, 'deselect': False, 'id': id, 'prev_id': prev_id, - 'match': False, 'match_count': xblock.match_count, - 'matches_remaining': xblock.matches_remaining, 'list': xblock.list, - 'list_length': xblock.list_length, 'id_list': xblock.matching_id_list, - 'id_dictionary': xblock.matching_id_dictionary, 'time_seconds': xblock.time_seconds + "first_selection": False, + "deselect": False, + "id": id, + "prev_id": prev_id, + "match": False, + "match_count": xblock.match_count, + "matches_remaining": xblock.matches_remaining, + "list": xblock.list, + "list_length": xblock.list_length, + "id_list": xblock.matching_id_list, + "id_dictionary": xblock.matching_id_dictionary, + "time_seconds": xblock.time_seconds, } @staticmethod - def end_game_matching(xblock, data, suffix=''): + def end_game_matching(xblock, data, suffix=""): """End the matching game and compare the user's time to the best_time field.""" xblock.game_started = False xblock.time_seconds = 0 @@ -146,7 +192,7 @@ def end_game_matching(xblock, data, suffix=''): xblock.match_count = 0 xblock.matches_remaining = xblock.list_length - new_time = data['newTime'] + new_time = data["newTime"] prev_time = xblock.best_time new_record = False first_attempt = False @@ -157,8 +203,10 @@ def end_game_matching(xblock, data, suffix=''): new_record = True xblock.best_time = new_time return { - 'new_time': new_time, 'prev_time': prev_time, - 'new_record': new_record, 'first_attempt': first_attempt + "new_time": new_time, + "prev_time": prev_time, + "new_record": new_record, + "first_attempt": first_attempt, } if new_time < xblock.best_time: @@ -166,6 +214,8 @@ def end_game_matching(xblock, data, suffix=''): xblock.best_time = new_time return { - 'new_time': new_time, 'prev_time': prev_time, - 'new_record': new_record, 'first_attempt': first_attempt + "new_time": new_time, + "prev_time": prev_time, + "new_record": new_record, + "first_attempt": first_attempt, } diff --git a/games/toggles.py b/games/toggles.py new file mode 100644 index 0000000..e733b9f --- /dev/null +++ b/games/toggles.py @@ -0,0 +1,25 @@ +""" +Toggles for games xblock. +""" + +from edx_toggles.toggles import WaffleFlag + +# .. toggle_name: legacy_studio.enable_games_xblock +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable the games xblock +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2025-11-21 +# .. toggle_target_removal_date: 2026-02-21 +ENABLE_GAMES_XBLOCK = WaffleFlag( + "legacy_studio.enable_games_xblock", + module_name=__name__, + log_prefix="games_xblock", +) + + +def is_games_xblock_enabled(): + """ + Return Waffle flag for enabling the games xblock on legacy studio. + """ + return ENABLE_GAMES_XBLOCK.is_enabled() diff --git a/games/utils.py b/games/utils.py new file mode 100644 index 0000000..df42b39 --- /dev/null +++ b/games/utils.py @@ -0,0 +1,49 @@ +""" +Utility methods for xblock +""" + +import logging +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured +from django.core.files.storage import default_storage +from django.utils.module_loading import import_string + +log = logging.getLogger(__name__) + + +def get_gamesxblock_storage(): + """ + Returns storage for gamesxblock assets. + + If GAMESXBLOCK_STORAGE is not defined for S3, returns default_storage. + """ + storage_settings = getattr(settings, "GAMESXBLOCK_STORAGE", None) + if not storage_settings: + return default_storage + + storage_class = storage_settings.get("storage_class") + storage_kwargs = storage_settings.get("settings", {}) or {} + + if not storage_class: + raise ImproperlyConfigured("GAMESXBLOCK_STORAGE.storage_class missing") + + try: + storage_class = import_string(storage_class) + except Exception as e: + raise ImproperlyConfigured( + f"Failed importing storage class {storage_class}: {e}" + ) from e + + try: + return storage_class(**storage_kwargs) + except Exception as e: + raise ImproperlyConfigured( + f"Failed initializing storage {storage_class} with {storage_kwargs}: {e}" + ) from e + + +def delete_image(storage, key: str): + if storage.exists(key): + storage.delete(key) + return True + return False diff --git a/setup.py b/setup.py index e7db44e..388de14 100644 --- a/setup.py +++ b/setup.py @@ -34,10 +34,12 @@ def package_data(pkg, roots): "XBlock>=1.2.0", "web-fragments>=0.3.0", "Django>=2.2", + "django-waffle==5.0.0", + "edx-toggles==5.4.1", ], entry_points={ "xblock.v1": [ - "games = games.games:GamesXBlock", + "games = games:GamesXBlock", ] }, package_data=package_data("games", ["static", "public", "locale"]),