diff --git a/.travis.yml b/.travis.yml index 4ccad346db..33cd6f856a 100755 --- a/.travis.yml +++ b/.travis.yml @@ -93,4 +93,4 @@ notifications: email: on_success: change on_failure: always - slack: cloudcv:gy3CGQGNXLwXOqVyzXGZfdea \ No newline at end of file + slack: cloudcv:gy3CGQGNXLwXOqVyzXGZfdea diff --git a/apps/base/utils.py b/apps/base/utils.py index 4d487f5c8c..8c8e93f788 100644 --- a/apps/base/utils.py +++ b/apps/base/utils.py @@ -11,6 +11,7 @@ import requests import sendgrid from django.conf import settings +from django.core import serializers from django.utils.deconstruct import deconstructible from rest_framework.exceptions import NotFound from rest_framework.pagination import PageNumberPagination @@ -333,3 +334,10 @@ def is_user_a_staff(user): {bool} : True/False if the user is staff or not """ return user.is_staff + + +def deserialize_object(object): + deserialized_object = None + for obj in serializers.deserialize("json", object): + deserialized_object = obj.object + return deserialized_object diff --git a/apps/challenges/github_interface.py b/apps/challenges/github_interface.py new file mode 100644 index 0000000000..d8f720b05c --- /dev/null +++ b/apps/challenges/github_interface.py @@ -0,0 +1,400 @@ +import base64 +import logging + +import requests +import yaml + +logger = logging.getLogger(__name__) + +URLS = {"contents": "/repos/{}/contents/{}", "repos": "/repos/{}"} + + +class GithubInterface: + def __init__(self, GITHUB_REPOSITORY, GITHUB_BRANCH, GITHUB_AUTH_TOKEN): + self.GITHUB_AUTH_TOKEN = GITHUB_AUTH_TOKEN + self.GITHUB_REPOSITORY = GITHUB_REPOSITORY + self.BRANCH = GITHUB_BRANCH or "challenge" + self.COMMIT_PREFIX = "evalai_bot: Update {}" + + def get_request_headers(self): + headers = {"Authorization": "token {}".format(self.GITHUB_AUTH_TOKEN)} + return headers + + def make_request(self, url, method, params={}, data={}): + url = self.get_github_url(url) + headers = self.get_request_headers() + try: + response = requests.request( + method=method, + url=url, + headers=headers, + params=params, + json=data, + ) + response.raise_for_status() + except requests.exceptions.RequestException: + logger.info( + "EvalAI is not able to establish connection with github {}".format( + response.json() + ) + ) + return None + return response.json() + + def get_github_url(self, url): + base_url = "https://api.github.com" + url = "{0}{1}".format(base_url, url) + return url + + def get_content_from_path(self, path): + """ + Gets the file content, information in json format in the repository at particular path + Ref: https://docs.github.com/en/rest/reference/repos#contents + """ + url = URLS.get("contents").format(self.GITHUB_REPOSITORY, path) + params = {"ref": self.BRANCH} + response = self.make_request(url, "GET", params) + return response + + def get_data_from_path(self, path): + """ + Gets the file data in string format in the repository at particular path + Calls get_content_from_path and encode the base64 content + """ + content_response = self.get_content_from_path(path) + string_data = None + if content_response and content_response.get("content"): + string_data = base64.b64decode(content_response["content"]).decode( + "utf-8", errors="ignore" + ) + return string_data + + def update_content_from_path(self, path, content, changed_field=None): + """ + Updates the file content, creates a commit in the repository at particular path + Ref: https://docs.github.com/en/rest/reference/repos#create-or-update-file-contents + """ + url = URLS.get("contents").format(self.GITHUB_REPOSITORY, path) + + # Get existing content to get SHA (required for updates) + existing_content = self.get_content_from_path(path) + + # Create specific commit message + if changed_field: + commit_message = ( + f"evalai_bot: Update {path} - changed field: {changed_field}" + ) + else: + commit_message = self.COMMIT_PREFIX.format(path) + + if existing_content and existing_content.get("sha"): + # File exists, update it + data = { + "message": commit_message, + "branch": self.BRANCH, + "sha": existing_content.get("sha"), + "content": content, + } + else: + # File doesn't exist, create it + data = { + "message": commit_message, + "branch": self.BRANCH, + "content": content, + } + + response = self.make_request(url, "PUT", data=data) + return response + + def update_data_from_path(self, path, data, changed_field=None): + """ + Updates the file data to the data(string) provided, at particular path + Call update_content_from_path with decoded base64 content + """ + content = base64.b64encode(bytes(data, "utf-8")).decode("utf-8") + return self.update_content_from_path(path, content, changed_field) + + def is_repository(self): + url = URLS.get("repos").format(self.GITHUB_REPOSITORY) + repo_response = self.make_request(url, "GET") + return True if repo_response else False + + def _read_text_from_file_field(self, value): + """Best-effort read of text from a Django FileField-like value.""" + if value is None: + return None + try: + # Django FieldFile has open/read + if hasattr(value, "open"): + value.open("rb") + data = value.read() + value.close() + elif hasattr(value, "read"): + data = value.read() + else: + data = str(value) + if isinstance(data, bytes): + try: + return data.decode("utf-8") + except Exception: + return data.decode("latin-1", errors="ignore") + return str(data) + except Exception: + return None + + def update_challenge_config(self, challenge, changed_field): + """ + Update challenge configuration in GitHub repository + Only updates the specific field that changed + """ + try: + # Get existing challenge config to preserve structure + existing_config = self.get_data_from_path("challenge_config.yaml") + if existing_config: + try: + config_data = yaml.safe_load(existing_config) + if not isinstance(config_data, dict): + config_data = {} + except yaml.YAMLError: + logger.warning( + "Existing challenge_config.yaml is not valid YAML, starting fresh" + ) + config_data = {} + else: + config_data = {} + + # File fields logic (update the referenced file content) + if changed_field in {"evaluation_script"}: + file_path = config_data.get(changed_field) + if not file_path: + logger.warning( + f"No path for '{changed_field}' in challenge_config.yaml; skipping file update" + ) + return False + current_text = self.get_data_from_path(file_path) + new_text = self._read_text_from_file_field( + getattr(challenge, changed_field, None) + ) + if new_text is None or new_text == current_text: + return True + return ( + True + if self.update_data_from_path( + file_path, new_text, changed_field + ) + else False + ) + + # Non-file field: update YAML key with processed value + if hasattr(challenge, changed_field): + current_value = getattr(challenge, changed_field) + processed_value = self._process_field_value( + changed_field, current_value + ) + if processed_value is None: + logger.warning( + f"Could not process changed field: {changed_field}" + ) + return False + # Skip if value unchanged to avoid empty commit + if config_data.get(changed_field) == processed_value: + return True + config_data[changed_field] = processed_value + else: + logger.error( + f"Field {changed_field} not found on challenge model" + ) + return False + + # Convert back to YAML + yaml_content = yaml.dump( + config_data, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) + + # Add documentation header + header_comment = "# If you are not sure what all these fields mean, please refer our documentation here:\n# https://evalai.readthedocs.io/en/latest/configuration.html\n" + yaml_content = header_comment + yaml_content + + # Update the file in GitHub + success = self.update_data_from_path( + "challenge_config.yaml", yaml_content, changed_field + ) + return True if success else False + + except Exception as e: + logger.error(f"Error updating challenge config: {str(e)}") + return False + + def update_challenge_phase_config(self, challenge_phase, changed_field): + """ + Update challenge phase configuration in GitHub repository + Only updates the specific field that changed + """ + try: + # Get existing challenge config to preserve structure + existing_config = self.get_data_from_path("challenge_config.yaml") + if existing_config: + try: + config_data = yaml.safe_load(existing_config) + if not isinstance(config_data, dict): + config_data = {} + except yaml.YAMLError: + logger.warning( + "Existing challenge_config.yaml is not valid YAML, starting fresh" + ) + config_data = {} + else: + config_data = {} + + # Initialize challenge_phases section if it doesn't exist + if "challenge_phases" not in config_data: + config_data["challenge_phases"] = [] + + # Locate the target phase by codename + target_index = None + for i, phase in enumerate(config_data["challenge_phases"]): + if phase.get("codename") == getattr( + challenge_phase, "codename", None + ): + target_index = i + break + if target_index is None: + logger.error( + f"Phase with codename {getattr(challenge_phase, 'codename', None)} not found" + ) + return False + + # File field mapping in YAML + yaml_key_map = {"test_annotation": "test_annotation_file"} + yaml_key = yaml_key_map.get(changed_field, changed_field) + + # File field for phase: update referenced file content + if changed_field in {"test_annotation"}: + file_path = config_data["challenge_phases"][target_index].get( + yaml_key + ) + if not file_path: + logger.warning( + f"No path for '{yaml_key}' in challenge_config.yaml; skipping file update" + ) + return False + current_text = self.get_data_from_path(file_path) + new_text = self._read_text_from_file_field( + getattr(challenge_phase, changed_field, None) + ) + if new_text is None or new_text == current_text: + return True + return ( + True + if self.update_data_from_path( + file_path, new_text, changed_field + ) + else False + ) + + # Non-file field: update YAML entry for that phase + if hasattr(challenge_phase, changed_field): + value = getattr(challenge_phase, changed_field) + processed_value = self._process_field_value( + changed_field, value + ) + if processed_value is None: + logger.warning( + f"Could not process changed phase field: {changed_field}" + ) + return False + # Skip if unchanged + if ( + config_data["challenge_phases"][target_index].get(yaml_key) + == processed_value + ): + return True + config_data["challenge_phases"][target_index][ + yaml_key + ] = processed_value + else: + logger.error( + f"Field {changed_field} not found on challenge_phase model" + ) + return False + + # Convert back to YAML + yaml_content = yaml.dump( + config_data, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) + + # Update the file in GitHub + success = self.update_data_from_path( + "challenge_config.yaml", yaml_content, changed_field + ) + return True if success else False + + except Exception as e: + logger.error(f"Error updating challenge phase config: {str(e)}") + return False + + def _process_field_value(self, field, value): + """ + Process a field value for GitHub sync + Returns the processed value or None if processing failed + """ + if value is None: + return None + + try: + if field in ["start_date", "end_date"] and hasattr( + value, "strftime" + ): + return value.strftime("%Y-%m-%d %H:%M:%S") + elif ( + field + in [ + "description", + "evaluation_details", + "terms_and_conditions", + "submission_guidelines", + ] + and value + ): + # Extract the actual content from HTML fields + if hasattr(value, "read"): + try: + value.seek(0) + content = value.read().decode("utf-8") + return content + except Exception: + return str(value) + else: + return str(value) + elif field in ["image", "evaluation_script"] and value: + # For YAML, store filename/path if available + if hasattr(value, "name"): + return value.name + else: + return str(value) + elif isinstance(value, (list, tuple)): + clean_list = [] + for item in value: + if hasattr(item, "pk"): + clean_list.append(item.pk) + elif hasattr(item, "id"): + clean_list.append(item.id) + else: + clean_list.append(item) + return clean_list + else: + if hasattr(value, "pk"): + return value.pk + elif hasattr(value, "id"): + return value.id + else: + return value + except Exception as e: + logger.error(f"Error processing field {field}: {str(e)}") + return None diff --git a/apps/challenges/github_sync_config.py b/apps/challenges/github_sync_config.py new file mode 100644 index 0000000000..8c8995833e --- /dev/null +++ b/apps/challenges/github_sync_config.py @@ -0,0 +1,61 @@ +# Fields from Challenge, ChallengePhase model to be considered for github_sync +# If you are not sure what all these fields mean, please refer our documentation here: +# https://evalai.readthedocs.io/en/latest/configuration.html + +challenge_non_file_fields = [ + "title", + "short_description", + "leaderboard_description", + "remote_evaluation", + "is_docker_based", + "is_static_dataset_code_upload", + "start_date", + "end_date", + "published", + "image", + "evaluation_script", + "tags", +] + +challenge_file_fields = [ + "description", + "evaluation_details", + "terms_and_conditions", + "submission_guidelines", +] + +challenge_phase_non_file_fields = [ + "id", + "name", + "leaderboard_public", + "is_public", + "challenge", + "is_active", + "max_concurrent_submissions_allowed", + "allowed_email_ids", + "disable_logs", + "is_submission_public", + "start_date", + "end_date", + "test_annotation_file", + "codename", + "max_submissions_per_day", + "max_submissions_per_month", + "max_submissions", + "is_restricted_to_select_one_submission", + "is_partial_submission_evaluation_enabled", + "allowed_submission_file_types", + "default_submission_meta_attributes", + "submission_meta_attributes", +] + +challenge_phase_file_fields = [ + "description", +] + +# Additional sections that should be synced +challenge_additional_sections = [ + "leaderboard", + "dataset_splits", + "challenge_phase_splits", +] diff --git a/apps/challenges/github_utils.py b/apps/challenges/github_utils.py new file mode 100644 index 0000000000..a0c42686ce --- /dev/null +++ b/apps/challenges/github_utils.py @@ -0,0 +1,130 @@ +import logging +import threading + +from .github_interface import GithubInterface +from .models import Challenge, ChallengePhase + +logger = logging.getLogger(__name__) + +# Thread-local storage to prevent recursive GitHub sync calls +_github_sync_context = threading.local() + + +def github_challenge_sync(challenge_id, changed_field): + """ + Simple sync from EvalAI to GitHub + This is the core function that keeps GitHub in sync with EvalAI + """ + try: + # Set flag to prevent recursive calls + _github_sync_context.skip_github_sync = True + _github_sync_context.change_source = "github" + + # Ensure changed_field is a string + if not isinstance(changed_field, str): + logger.error( + f"Invalid changed_field type: {type(changed_field)}, expected string" + ) + return False + + challenge = Challenge.objects.get(id=challenge_id) + + if not challenge.github_repository or not challenge.github_token: + logger.warning( + f"Challenge {challenge_id} missing GitHub configuration" + ) + return False + + # Initialize GitHub interface + github_interface = GithubInterface( + challenge.github_repository, + challenge.github_branch + or "challenge", # Default to 'challenge' branch + challenge.github_token, + ) + + # Update challenge config in GitHub with the specific changed field + success = github_interface.update_challenge_config( + challenge, changed_field + ) + + if success: + return True + else: + logger.error(f"Failed to sync challenge {challenge_id} to GitHub") + return False + + except Challenge.DoesNotExist: + logger.error(f"Challenge {challenge_id} not found") + return False + except Exception: + logger.exception(f"Error syncing challenge {challenge_id} to GitHub") + return False + finally: + # Always clean up the flags + if hasattr(_github_sync_context, "skip_github_sync"): + delattr(_github_sync_context, "skip_github_sync") + if hasattr(_github_sync_context, "change_source"): + delattr(_github_sync_context, "change_source") + + +def github_challenge_phase_sync(challenge_phase_id, changed_field): + """ + Sync challenge phase from EvalAI to GitHub + """ + try: + # Set flag to prevent recursive calls + _github_sync_context.skip_github_sync = True + _github_sync_context.change_source = "github" + + # Ensure changed_field is a string + if not isinstance(changed_field, str): + logger.error( + f"Invalid changed_field type: {type(changed_field)}, expected string" + ) + return False + + challenge_phase = ChallengePhase.objects.get(id=challenge_phase_id) + challenge = challenge_phase.challenge + + if not challenge.github_repository or not challenge.github_token: + logger.warning( + f"Challenge {challenge.id} missing GitHub configuration" + ) + return False + + # Initialize GitHub interface + github_interface = GithubInterface( + challenge.github_repository, + challenge.github_branch + or "challenge", # Default to 'challenge' branch + challenge.github_token, + ) + + # Update challenge phase config in GitHub with the specific changed field + success = github_interface.update_challenge_phase_config( + challenge_phase, changed_field + ) + + if success: + return True + else: + logger.error( + f"Failed to sync challenge phase {challenge_phase_id} to GitHub" + ) + return False + + except ChallengePhase.DoesNotExist: + logger.error(f"Challenge phase {challenge_phase_id} not found") + return False + except Exception: + logger.exception( + f"Error syncing challenge phase {challenge_phase_id} to GitHub" + ) + return False + finally: + # Always clean up the flags + if hasattr(_github_sync_context, "skip_github_sync"): + delattr(_github_sync_context, "skip_github_sync") + if hasattr(_github_sync_context, "change_source"): + delattr(_github_sync_context, "change_source") diff --git a/apps/challenges/migrations/0113_add_github_branch_field_and_unique_constraint.py b/apps/challenges/migrations/0113_add_github_branch_field_and_unique_constraint.py index 54d121849c..278a489c51 100644 --- a/apps/challenges/migrations/0113_add_github_branch_field_and_unique_constraint.py +++ b/apps/challenges/migrations/0113_add_github_branch_field_and_unique_constraint.py @@ -1,6 +1,6 @@ # Generated by Django 2.2.20 on 2025-07-09 15:00 -from django.db import migrations +from django.db import migrations, models class Migration(migrations.Migration): @@ -42,4 +42,17 @@ class Migration(migrations.Migration): "challenge_github_repo_branch_partial_idx;" ), ), + # Record the field in Django state without changing the database again + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AddField( + model_name="challenge", + name="github_branch", + field=models.CharField( + max_length=200, null=True, blank=True, default="" + ), + ), + ], + ), ] diff --git a/apps/challenges/migrations/0114_add_github_token_field.py b/apps/challenges/migrations/0114_add_github_token_field.py new file mode 100644 index 0000000000..489724c232 --- /dev/null +++ b/apps/challenges/migrations/0114_add_github_token_field.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.20 on 2025-08-18 07:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("challenges", "0113_add_github_branch_field_and_unique_constraint"), + ] + + operations = [ + migrations.AddField( + model_name="challenge", + name="github_token", + field=models.CharField( + blank=True, default="", max_length=200, null=True + ), + ), + ] diff --git a/apps/challenges/models.py b/apps/challenges/models.py index 823309d9d1..126e608dad 100644 --- a/apps/challenges/models.py +++ b/apps/challenges/models.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import json +import logging +import threading + from base.models import TimeStampedModel, model_field_name from base.utils import RandomFileName, get_slug, is_model_field_changed from django.contrib.auth.models import User @@ -10,9 +14,112 @@ from django.db.models.signals import pre_save from django.dispatch import receiver from django.utils import timezone +from django.utils.deprecation import MiddlewareMixin from hosts.models import ChallengeHost from participants.models import ParticipantTeam +logger = logging.getLogger(__name__) + +# Thread-local storage to prevent recursive GitHub sync calls +_github_sync_context = threading.local() + + +# Request-level context to track GitHub sync operations +class GitHubSyncContext: + def __init__(self): + self.is_syncing = False + self.synced_challenges = set() + self.synced_phases = set() + + def mark_syncing(self, challenge_id=None, phase_id=None): + """Mark that we're currently syncing""" + self.is_syncing = True + if challenge_id: + self.synced_challenges.add(challenge_id) + if phase_id: + self.synced_phases.add(phase_id) + + def is_already_synced(self, challenge_id=None, phase_id=None): + """Check if we've already synced this challenge/phase in this request""" + if challenge_id and challenge_id in self.synced_challenges: + return True + if phase_id and phase_id in self.synced_phases: + return True + return False + + +# Global context for the current request +_github_request_context = GitHubSyncContext() + +# Thread-local store for current request payload keys +_github_request_local = threading.local() + + +def reset_github_sync_context(): + """Reset the GitHub sync context for a new request""" + global _github_request_context + _github_request_context = GitHubSyncContext() + # also clear payload keys + if hasattr(_github_request_local, "payload_keys"): + delattr(_github_request_local, "payload_keys") + # reset per-request sync context + + +class GitHubSyncMiddleware(MiddlewareMixin): + """Middleware to reset GitHub sync context on each request and capture payload keys""" + + def process_request(self, request): + """Reset context at the start of each request; capture payload keys for inference""" + reset_github_sync_context() + try: + keys = [] + if request.method in ("PATCH", "PUT", "POST"): + # Try JSON body first + if getattr(request, "body", None): + try: + payload = json.loads(request.body.decode("utf-8")) + if isinstance(payload, dict): + keys = list(payload.keys()) + except Exception: + keys = [] + # Fallback to POST dict + if not keys and hasattr(request, "POST"): + try: + keys = list(request.POST.keys()) + except Exception: + keys = [] + _github_request_local.payload_keys = keys + except Exception: + _github_request_local.payload_keys = [] + return None + + +def _infer_changed_field_from_request(model_instance): + """Infer a single changed field from the current request payload keys. + Returns a field name string or None. + """ + try: + keys = getattr(_github_request_local, "payload_keys", []) or [] + if not keys: + return None + # Prefer keys that actually exist on the model + for key in keys: + # Ignore meta and non-model keys + if key in { + "id", + "pk", + "challenge", + "phase", + "created_at", + "modified_at", + }: + continue + if hasattr(model_instance, key): + return key + return None + except Exception: + return None + @receiver(pre_save, sender="challenges.Challenge") def save_challenge_slug(sender, instance, **kwargs): @@ -190,6 +297,10 @@ def __init__(self, *args, **kwargs): github_branch = models.CharField( max_length=200, null=True, blank=True, default="" ) + # The github token used for authentication + github_token = models.CharField( + max_length=200, null=True, blank=True, default="" + ) # The number of vCPU for a Fargate worker for the challenge. Default value # is 0.25 vCPU. worker_cpu_cores = models.IntegerField(null=True, blank=True, default=512) @@ -315,6 +426,73 @@ def update_sqs_retention_period_for_challenge( challenge.save() +@receiver(signals.post_save, sender="challenges.Challenge") +def challenge_details_sync(sender, instance, created, **kwargs): + """Sync challenge details to GitHub when challenge is updated""" + # Skip if this is a bot-triggered save to prevent recursive calls + if ( + hasattr(_github_sync_context, "skip_github_sync") + and _github_sync_context.skip_github_sync + ): + logger.info( + f"Skipping GitHub sync for challenge {instance.id} - recursive call prevented" + ) + return + + # Skip if this is a GitHub-sourced change (not from UI) + if ( + hasattr(_github_sync_context, "change_source") + and _github_sync_context.change_source == "github" + ): + logger.info( + f"Skipping GitHub sync for challenge {instance.id} - change sourced from GitHub" + ) + return + + # Skip if we've already synced this challenge in this request + if _github_request_context.is_already_synced(challenge_id=instance.id): + logger.info( + f"Skipping GitHub sync for challenge {instance.id} - already synced in this request" + ) + return + + # By default, allow UI changes to trigger GitHub sync + # proceed only for updates with github configured + if not created and instance.github_token and instance.github_repository: + try: + from challenges.github_utils import github_challenge_sync + + _github_request_context.mark_syncing(challenge_id=instance.id) + + # Get the changed field from update_fields if available + changed_field = None + if kwargs.get("update_fields"): + # Django provides update_fields when using .save(update_fields=['field_name']) + changed_field = ( + list(kwargs["update_fields"])[0] + if kwargs["update_fields"] + else None + ) + pass + # Infer from request payload if not provided + if not changed_field: + inferred = _infer_changed_field_from_request(instance) + if inferred: + changed_field = inferred + pass + + # Require a specific changed field to proceed (single-field commit intent) + if not isinstance(changed_field, str) or not changed_field: + # skip if we cannot determine a single changed field + return + + github_challenge_sync(instance.id, changed_field=changed_field) + except Exception as e: + logger.error(f"Error in challenge_details_sync: {str(e)}") + else: + pass + + class DatasetSplit(TimeStampedModel): name = models.CharField(max_length=100) codename = models.CharField(max_length=100) @@ -441,6 +619,79 @@ def save(self, *args, **kwargs): return challenge_phase_instance +@receiver(signals.post_save, sender="challenges.ChallengePhase") +def challenge_phase_details_sync(sender, instance, created, **kwargs): + """Sync challenge phase details to GitHub when challenge phase is updated""" + # Skip if this is a bot-triggered save to prevent recursive calls + if ( + hasattr(_github_sync_context, "skip_github_sync") + and _github_sync_context.skip_github_sync + ): + logger.info( + f"Skipping GitHub sync for challenge phase {instance.id} - recursive call prevented" + ) + return + + # Skip if this is a GitHub-sourced change (not from UI) + if ( + hasattr(_github_sync_context, "change_source") + and _github_sync_context.change_source == "github" + ): + logger.info( + f"Skipping GitHub sync for challenge phase {instance.id} - change sourced from GitHub" + ) + return + + # Skip if we've already synced this phase in this request + if _github_request_context.is_already_synced(phase_id=instance.id): + logger.info( + f"Skipping GitHub sync for challenge phase {instance.id} - already synced in this request" + ) + return + + # By default, allow UI changes to trigger GitHub sync + # proceed only for updates with github configured + if ( + not created + and instance.challenge.github_token + and instance.challenge.github_repository + ): + try: + from challenges.github_utils import github_challenge_phase_sync + + _github_request_context.mark_syncing(phase_id=instance.id) + + # Get the changed field from update_fields if available + changed_field = None + if kwargs.get("update_fields"): + # Django provides update_fields when using .save(update_fields=['field_name']) + changed_field = ( + list(kwargs["update_fields"])[0] + if kwargs["update_fields"] + else None + ) + pass + # Infer from request payload if not provided + if not changed_field: + inferred = _infer_changed_field_from_request(instance) + if inferred: + changed_field = inferred + pass + + # Require a specific changed field to proceed (single-field commit intent) + if not isinstance(changed_field, str) or not changed_field: + # skip if we cannot determine a single changed field + return + + github_challenge_phase_sync( + instance.id, changed_field=changed_field + ) + except Exception as e: + logger.error(f"Error in challenge_phase_details_sync: {str(e)}") + else: + pass + + def post_save_connect(field_name, sender): import challenges.aws_utils as aws diff --git a/apps/challenges/serializers.py b/apps/challenges/serializers.py index 4e933be0a6..c8393d47b4 100644 --- a/apps/challenges/serializers.py +++ b/apps/challenges/serializers.py @@ -34,6 +34,9 @@ def __init__(self, *args, **kwargs): if context and context.get("request").method != "GET": challenge_host_team = context.get("challenge_host_team") kwargs["data"]["creator"] = challenge_host_team.pk + github_token = context.get("github_token") + if github_token: + kwargs["data"]["github_token"] = github_token else: self.fields["creator"] = ChallengeHostTeamSerializer() @@ -96,6 +99,7 @@ class Meta: "sqs_retention_period", "github_repository", "github_branch", + "github_token", ) @@ -260,6 +264,9 @@ def __init__(self, *args, **kwargs): github_branch = context.get("github_branch") if github_branch: kwargs["data"]["github_branch"] = github_branch + github_token = context.get("github_token") + if github_token: + kwargs["data"]["github_token"] = github_token class Meta: model = Challenge @@ -299,6 +306,7 @@ class Meta: "cli_version", "github_repository", "github_branch", + "github_token", "vpc_cidr", "subnet_1_cidr", "subnet_2_cidr", diff --git a/apps/challenges/urls.py b/apps/challenges/urls.py index 3139d5381c..f375bcbbe0 100644 --- a/apps/challenges/urls.py +++ b/apps/challenges/urls.py @@ -14,7 +14,7 @@ name="get_challenge_detail", ), url( - r"^(?P[0-9]+)/participant_team/team_detail$", + r"^challenge/(?P[0-9]+)/participant_team/team_detail$", views.participant_team_detail_for_challenge, name="participant_team_detail_for_challenge", ), @@ -38,7 +38,6 @@ views.challenge_phase_detail, name="get_challenge_phase_detail", ), - # `A-Za-z` because it accepts either of `all, future, past or present` in either case url( r"^challenge/(?P[A-Za-z]+)/(?P[A-Za-z]+)/(?P[A-Za-z]+)$", views.get_all_challenges, diff --git a/apps/challenges/views.py b/apps/challenges/views.py index 87409330b9..c42fdfd5ad 100644 --- a/apps/challenges/views.py +++ b/apps/challenges/views.py @@ -205,11 +205,13 @@ def challenge_list(request, challenge_host_team_pk): context={ "challenge_host_team": challenge_host_team, "request": request, + "github_token": request.data.get("GITHUB_AUTH_TOKEN"), }, ) if serializer.is_valid(): serializer.save() challenge = get_challenge_model(serializer.instance.pk) + serializer = ChallengeSerializer(challenge) response_data = serializer.data return Response(response_data, status=status.HTTP_201_CREATED) @@ -256,6 +258,7 @@ def challenge_detail(request, challenge_host_team_pk, challenge_pk): context={ "challenge_host_team": challenge_host_team, "request": request, + "github_token": request.data.get("GITHUB_AUTH_TOKEN"), }, partial=True, ) @@ -271,6 +274,7 @@ def challenge_detail(request, challenge_host_team_pk, challenge_pk): context={ "challenge_host_team": challenge_host_team, "request": request, + "github_token": request.data.get("GITHUB_AUTH_TOKEN"), }, partial=True, ) @@ -286,6 +290,7 @@ def challenge_detail(request, challenge_host_team_pk, challenge_pk): context={ "challenge_host_team": challenge_host_team, "request": request, + "github_token": request.data.get("GITHUB_AUTH_TOKEN"), }, partial=True, ) @@ -301,6 +306,7 @@ def challenge_detail(request, challenge_host_team_pk, challenge_pk): context={ "challenge_host_team": challenge_host_team, "request": request, + "github_token": request.data.get("GITHUB_AUTH_TOKEN"), }, partial=True, ) @@ -311,6 +317,7 @@ def challenge_detail(request, challenge_host_team_pk, challenge_pk): context={ "challenge_host_team": challenge_host_team, "request": request, + "github_token": request.data.get("GITHUB_AUTH_TOKEN"), }, partial=True, ) @@ -321,11 +328,13 @@ def challenge_detail(request, challenge_host_team_pk, challenge_pk): context={ "challenge_host_team": challenge_host_team, "request": request, + "github_token": request.data.get("GITHUB_AUTH_TOKEN"), }, ) if serializer.is_valid(): serializer.save() challenge = get_challenge_model(serializer.instance.pk) + serializer = ChallengeSerializer(challenge) response_data = serializer.data return Response(response_data, status=status.HTTP_200_OK) @@ -1087,6 +1096,7 @@ def challenge_phase_detail(request, challenge_pk, pk): if serializer.is_valid(): serializer.save() challenge_phase = get_challenge_phase_model(serializer.instance.pk) + serializer = ChallengePhaseSerializer(challenge_phase) response_data = serializer.data return Response(response_data, status=status.HTTP_200_OK) @@ -1667,6 +1677,7 @@ def create_challenge_using_zip_file(request, challenge_host_team_pk): "challenge_host_team": challenge_host_team, "image": challenge_image_file, "evaluation_script": challenge_evaluation_script_file, + "github_token": request.data.get("GITHUB_AUTH_TOKEN"), }, ) if serializer.is_valid(): @@ -3980,6 +3991,9 @@ def create_or_update_github_challenge(request, challenge_host_team_pk): "github_repository": request.data[ "GITHUB_REPOSITORY" ], + "github_token": request.data.get( + "GITHUB_AUTH_TOKEN" + ), "worker_image_url": worker_image_url, }, ) @@ -4283,6 +4297,7 @@ def create_or_update_github_challenge(request, challenge_host_team_pk): "evaluation_script": files[ "challenge_evaluation_script_file" ], + "github_token": request.data.get("GITHUB_AUTH_TOKEN"), "worker_image_url": worker_image_url, }, ) diff --git a/requirements/common.txt b/requirements/common.txt index ffafed0a70..e51464faf7 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -31,5 +31,6 @@ pycurl==7.43.0.6 PyJWT==2.1.0 PyYaml==5.1 rstr==2.2.6 +requests==2.25.1 sendgrid==6.4.8 vine==1.3.0 diff --git a/settings/common.py b/settings/common.py index 3aa982e58d..8aef2ab95a 100755 --- a/settings/common.py +++ b/settings/common.py @@ -86,6 +86,7 @@ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", + "challenges.models.GitHubSyncMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", diff --git a/tests/unit/base/test_utils.py b/tests/unit/base/test_utils.py index 3701157fc0..68bce30ed7 100644 --- a/tests/unit/base/test_utils.py +++ b/tests/unit/base/test_utils.py @@ -1,3 +1,6 @@ +import json + + import os import unittest from datetime import timedelta @@ -25,6 +28,7 @@ from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile from django.utils import timezone +from django.test import TestCase as DjangoTestCase from hosts.models import ChallengeHostTeam from jobs.models import Submission from participants.models import Participant, ParticipantTeam @@ -403,3 +407,17 @@ def test_encode_data_empty_list(self): def test_decode_data_empty_list(self): data = [] self.assertEqual(decode_data(data), []) + + +class TestDeserializeObject(DjangoTestCase): + def test_deserialize_object_returns_model_instance(self): + from django.contrib.auth.models import User + from django.core import serializers as dj_serializers + from apps.base.utils import deserialize_object + + user = User.objects.create(username="alice", email="alice@example.com") + serialized = dj_serializers.serialize("json", [user]) + obj = deserialize_object(serialized) + self.assertIsNotNone(obj) + self.assertIsInstance(obj, User) + self.assertEqual(obj.pk, user.pk) diff --git a/tests/unit/challenges/test_github_sync_signals.py b/tests/unit/challenges/test_github_sync_signals.py new file mode 100644 index 0000000000..71a5ae4af0 --- /dev/null +++ b/tests/unit/challenges/test_github_sync_signals.py @@ -0,0 +1,209 @@ +from unittest.mock import patch + +from challenges import models as challenge_models +from challenges.models import ( + Challenge, + ChallengePhase, + GitHubSyncMiddleware, + reset_github_sync_context, +) +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone +from hosts.models import ChallengeHostTeam + + +class TestGithubSyncSignals(TestCase): + + def setUp(self): + # minimal creator for Challenge + self.user = User.objects.create( + username="owner", email="o@example.com" + ) + self.host_team = ChallengeHostTeam.objects.create( + team_name="team", created_by=self.user + ) + self.challenge = Challenge.objects.create( + title="Initial Title", + description="Desc", + github_token="test_token", + github_repository="org/repo", + github_branch="main", + creator=self.host_team, + start_date=timezone.now(), + end_date=timezone.now() + timezone.timedelta(days=5), + ) + self.phase = ChallengePhase.objects.create( + name="Initial Phase", + description="Phase Desc", + challenge=self.challenge, + codename="phase_code", + ) + + @patch("challenges.aws_utils.challenge_approval_callback") + @patch("challenges.github_utils.github_challenge_sync") + def test_challenge_post_save_calls_sync_with_update_fields( + self, mock_sync, _mock_approval_cb + ): + self.challenge.title = "Updated Title" + # Pass update_fields so receiver can read changed field directly + self.challenge.save(update_fields=["title"]) + + mock_sync.assert_called_once() + args, kwargs = mock_sync.call_args + self.assertEqual(args[0], self.challenge.id) + self.assertEqual(kwargs.get("changed_field"), "title") + + @patch("challenges.github_utils.github_challenge_phase_sync") + def test_phase_post_save_calls_sync_with_update_fields( + self, mock_phase_sync + ): + self.phase.name = "Updated Phase" + self.phase.save(update_fields=["name"]) + + mock_phase_sync.assert_called_once() + args, kwargs = mock_phase_sync.call_args + self.assertEqual(args[0], self.phase.id) + self.assertEqual(kwargs.get("changed_field"), "name") + + @patch("challenges.aws_utils.challenge_approval_callback") + @patch("challenges.github_utils.github_challenge_sync") + def test_middleware_infers_changed_field_and_triggers_sync( + self, mock_sync, _mock_approval_cb + ): + # Simulate a PATCH request payload captured by middleware + class _Req: + method = "PATCH" + body = b'{\n "title": "MW Title"\n}' + + mw = GitHubSyncMiddleware() + mw.process_request(_Req()) + + # Save without update_fields; receiver should infer from payload keys + self.challenge.title = "MW Title" + self.challenge.save() + + mock_sync.assert_called_once() + _args, kwargs = mock_sync.call_args + self.assertEqual(kwargs.get("changed_field"), "title") + + @patch("challenges.github_utils.github_challenge_sync") + def test_challenge_create_does_not_sync(self, mock_sync): + with patch("challenges.aws_utils.challenge_approval_callback"): + Challenge.objects.create( + title="New Challenge", + description="Desc", + github_token="test_token", + github_repository="org/repo", + github_branch="feature/test", # avoid unique (repo, branch) conflict + creator=self.host_team, + start_date=timezone.now(), + end_date=timezone.now() + timezone.timedelta(days=5), + ) + + mock_sync.assert_not_called() + + @patch("challenges.aws_utils.challenge_approval_callback") + @patch("challenges.github_utils.github_challenge_sync") + def test_no_sync_without_github_config(self, mock_sync, _mock_approval_cb): + self.challenge.github_token = "" + self.challenge.save( + update_fields=["github_token"] + ) # change without config + mock_sync.assert_not_called() + + @patch("challenges.aws_utils.challenge_approval_callback") + @patch("challenges.github_utils.github_challenge_sync") + def test_dedupe_within_single_request(self, mock_sync, _mock_approval_cb): + # Start request, middleware captures keys + class _Req: + method = "PATCH" + body = b'{\n "title": "One"\n}' + + mw = GitHubSyncMiddleware() + mw.process_request(_Req()) + + # Two saves in same request + self.challenge.title = "One" + self.challenge.save() + self.challenge.description = "Changed" + self.challenge.save() + + # Only the first should sync for this challenge in this request + mock_sync.assert_called_once() + + @patch("challenges.aws_utils.challenge_approval_callback") + @patch("challenges.github_utils.github_challenge_sync") + def test_skip_when_change_source_is_github( + self, mock_sync, _mock_approval_cb + ): + # Simulate a GitHub-sourced change via models' sync context + challenge_models._github_sync_context.change_source = "github" + try: + self.challenge.title = "Ignored" + self.challenge.save(update_fields=["title"]) + mock_sync.assert_not_called() + finally: + if hasattr(challenge_models._github_sync_context, "change_source"): + delattr(challenge_models._github_sync_context, "change_source") + + @patch("challenges.aws_utils.challenge_approval_callback") + @patch("challenges.github_utils.github_challenge_sync") + def test_skip_when_skip_github_sync_flag_set( + self, mock_sync, _mock_approval_cb + ): + # When internal skip flag is set, no sync should happen + challenge_models._github_sync_context.skip_github_sync = True + try: + self.challenge.title = "Ignored by flag" + self.challenge.save(update_fields=["title"]) + mock_sync.assert_not_called() + finally: + if hasattr( + challenge_models._github_sync_context, "skip_github_sync" + ): + delattr( + challenge_models._github_sync_context, "skip_github_sync" + ) + + @patch("challenges.aws_utils.challenge_approval_callback") + @patch("challenges.github_utils.github_challenge_sync") + def test_no_changed_field_inference_means_no_sync( + self, mock_sync, _mock_approval_cb + ): + # Ensure no payload keys available to infer + reset_github_sync_context() + self.challenge.title = "Still Updated" + self.challenge.save() # no update_fields, no middleware keys + mock_sync.assert_not_called() + + @patch("challenges.aws_utils.challenge_approval_callback") + @patch("challenges.github_utils.github_challenge_sync") + def test_multiple_update_fields_prefers_first( + self, mock_sync, _mock_approval_cb + ): + self.challenge.title = "A" + self.challenge.description = "B" + self.challenge.save(update_fields=["title", "description"]) + _args, kwargs = mock_sync.call_args + # Order of update_fields is non-deterministic (Django converts to set), accept either + assert kwargs.get("changed_field") in {"title", "description"} + + @patch("challenges.github_utils.github_challenge_phase_sync") + def test_phase_middleware_infers_changed_field_and_triggers_sync( + self, mock_phase_sync + ): + # Simulate request payload for phase update + class _Req: + method = "PATCH" + body = b'{\n "name": "New Phase Name"\n}' + + mw = challenge_models.GitHubSyncMiddleware() + mw.process_request(_Req()) + + self.phase.name = "New Phase Name" + self.phase.save() + + mock_phase_sync.assert_called_once() + _args, kwargs = mock_phase_sync.call_args + self.assertEqual(kwargs.get("changed_field"), "name") diff --git a/tests/unit/challenges/test_github_utils.py b/tests/unit/challenges/test_github_utils.py new file mode 100644 index 0000000000..0ecfbb10b1 --- /dev/null +++ b/tests/unit/challenges/test_github_utils.py @@ -0,0 +1,600 @@ +import datetime as dt +import logging +from unittest.mock import Mock, patch + +from challenges import github_utils as gu +from challenges.github_interface import GithubInterface +from challenges.github_utils import ( + github_challenge_phase_sync, + github_challenge_sync, +) +from challenges.models import Challenge, ChallengePhase +from django.contrib.auth.models import User +from django.test import TestCase +from django.utils import timezone +from hosts.models import ChallengeHostTeam + + +class TestGithubInterface(TestCase): + """Test cases for GithubInterface class""" + + def setUp(self): + self.token = "test_token" + self.repo = "test/repo" + self.branch = "master" + self.github = GithubInterface(self.repo, self.branch, self.token) + + def test_init(self): + """Test GithubInterface initialization""" + self.assertEqual(self.github.GITHUB_AUTH_TOKEN, self.token) + self.assertEqual(self.github.GITHUB_REPOSITORY, self.repo) + self.assertEqual(self.github.BRANCH, self.branch) + headers = self.github.get_request_headers() + self.assertIn("Authorization", headers) + + def test_get_github_url(self): + """Test get_github_url method""" + url = "/test/path" + expected = "https://api.github.com/test/path" + result = self.github.get_github_url(url) + self.assertEqual(result, expected) + + @patch("challenges.github_interface.requests.request") + def test_get_content_from_path_success(self, mock_request): + """Test get_content_from_path with successful response""" + mock_response = Mock() + mock_response.status_code = 200 + mock_response.json.return_value = {"content": "test content"} + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + + result = self.github.get_content_from_path("test.json") + + self.assertEqual(result, {"content": "test content"}) + mock_request.assert_called_once() + + @patch("challenges.github_interface.requests.request") + def test_get_content_from_path_not_found(self, mock_request): + """Test get_content_from_path with error response returns None""" + from requests.exceptions import RequestException + + mock_response = Mock() + mock_response.raise_for_status.side_effect = RequestException() + mock_request.return_value = mock_response + + result = self.github.get_content_from_path("test.json") + + self.assertIsNone(result) + + @patch("challenges.github_interface.GithubInterface.get_content_from_path") + @patch("challenges.github_interface.requests.request") + def test_update_content_from_path_success(self, mock_request, mock_get): + """Test update_content_from_path with successful response""" + # Simulate existing file with sha so update path is used + mock_get.return_value = {"sha": "old_sha"} + mock_response = Mock() + mock_response.json.return_value = {"sha": "new_sha"} + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + + result = self.github.update_content_from_path( + "test.json", "Y29udGVudA==", changed_field="title" + ) + + self.assertEqual(result, {"sha": "new_sha"}) + mock_request.assert_called_once() + + @patch("challenges.github_interface.requests.request") + def test_update_content_from_path_failure(self, mock_request): + """Test update_content_from_path with failed response returns None""" + from requests.exceptions import RequestException + + mock_response = Mock() + mock_response.raise_for_status.side_effect = RequestException() + mock_request.return_value = mock_response + + result = self.github.update_content_from_path( + "test.json", "Y29udGVudA==", changed_field="title" + ) + + self.assertIsNone(result) + + @patch( + "challenges.github_interface.GithubInterface.update_content_from_path" + ) + def test_update_data_from_path_encodes_and_calls_update(self, mock_update): + """update_data_from_path should base64-encode and call update_content_from_path""" + mock_update.return_value = {"sha": "new_sha"} + text = "hello" + result = self.github.update_data_from_path( + "test.json", text, changed_field="title" + ) + + self.assertEqual(result, {"sha": "new_sha"}) + mock_update.assert_called_once() + + @patch("challenges.github_interface.GithubInterface.get_content_from_path") + @patch("challenges.github_interface.requests.request") + def test_update_content_from_path_creates_when_missing( + self, mock_request, mock_get + ): + """When file is missing, create flow is used and no sha is sent""" + mock_get.return_value = None + mock_response = Mock() + mock_response.json.return_value = {"sha": "new_sha"} + mock_response.raise_for_status.return_value = None + mock_request.return_value = mock_response + + result = self.github.update_content_from_path( + "new.yaml", "Y29udGVudA==" + ) + + self.assertEqual(result, {"sha": "new_sha"}) + # Ensure PUT happened once + mock_request.assert_called_once() + + def test_process_field_value_formats_and_serializes(self): + # date formatting + date = dt.datetime(2023, 1, 2, 3, 4, 5) + self.assertEqual( + self.github._process_field_value("start_date", date), + "2023-01-02 03:04:05", + ) + + # list of objects -> list of ids + class Obj: + def __init__(self, pk): + self.pk = pk + + self.assertEqual( + self.github._process_field_value("list", [Obj(1), Obj(2)]), [1, 2] + ) + + # file-like/path-like + class Dummy: + name = "path/to/file.txt" + + self.assertEqual( + self.github._process_field_value("evaluation_script", Dummy()), + "path/to/file.txt", + ) + + def test_read_text_from_file_field_variants(self): + # value with open/read returning bytes + class FieldFileLike: + def __init__(self, data=b"hello"): + self._data = data + + def open(self, *_args, **_kwargs): + return None + + def read(self): + return self._data + + def close(self): + return None + + self.assertEqual( + self.github._read_text_from_file_field(FieldFileLike(b"hi")), "hi" + ) + + # value with only read + class ReadOnly: + def __init__(self, data=b"bye"): + self._data = data + + def read(self): + return self._data + + self.assertEqual( + self.github._read_text_from_file_field(ReadOnly(b"bye")), "bye" + ) + + # value without read/open + class Other: + def __str__(self): + return "stringified" + + self.assertEqual( + self.github._read_text_from_file_field(Other()), "stringified" + ) + + def test_get_data_from_path_none_when_no_content(self): + with patch.object( + self.github, "get_content_from_path", return_value={} + ): + assert self.github.get_data_from_path("x") is None + + def test_is_repository_true_false(self): + with patch.object(self.github, "make_request", return_value={"id": 1}): + assert self.github.is_repository() is True + with patch.object(self.github, "make_request", return_value=None): + assert self.github.is_repository() is False + + def test_update_challenge_config_non_file_changes(self): + # Existing YAML without key, then update data + with patch.object( + self.github, "get_data_from_path", return_value="title: Old\n" + ), patch.object( + self.github, "update_data_from_path", return_value={"ok": True} + ) as mock_update: + + class ChallengeObj: + title = "New" + start_date = None + end_date = None + + ok = self.github.update_challenge_config(ChallengeObj(), "title") + self.assertTrue(ok) + mock_update.assert_called_once() + + def test_update_challenge_config_skips_if_unchanged(self): + with patch.object( + self.github, "get_data_from_path", return_value="title: Same\n" + ): + + class ChallengeObj: + title = "Same" + + ok = self.github.update_challenge_config(ChallengeObj(), "title") + self.assertTrue(ok) # returns True but no update + + def test_update_challenge_config_file_field_missing_path(self): + # evaluation_script path missing in YAML + with patch.object( + self.github, "get_data_from_path", return_value="{}\n" + ): + + class ChallengeObj: + evaluation_script = object() + + ok = self.github.update_challenge_config( + ChallengeObj(), "evaluation_script" + ) + self.assertFalse(ok) + + def test_update_challenge_config_file_field_updates_when_changed(self): + # YAML with path, and new content different + with patch.object( + self.github, + "get_data_from_path", + side_effect=[ + "evaluation_script: path/file.txt\n", + "old-text", + ], + ), patch.object( + self.github, "update_data_from_path", return_value={"ok": True} + ) as mock_update: + + class FileLike: + def __init__(self, data): + self._data = data + + def read(self): + return self._data + + class ChallengeObj: + evaluation_script = FileLike(b"new-text") + + ok = self.github.update_challenge_config( + ChallengeObj(), "evaluation_script" + ) + self.assertTrue(ok) + mock_update.assert_called_once() + + def test_update_challenge_config_returns_false_on_process_failure(self): + with patch.object( + self.github, "get_data_from_path", return_value="{}\n" + ): + + class ChallengeObj: + # make _process_field_value return None by passing None + title = None + + ok = self.github.update_challenge_config(ChallengeObj(), "title") + self.assertFalse(ok) + + def test_update_challenge_phase_config_not_found_by_codename(self): + with patch.object( + self.github, + "get_data_from_path", + return_value="challenge_phases: []\n", + ): + + class PhaseObj: + codename = "missing" + challenge = object() + + ok = self.github.update_challenge_phase_config(PhaseObj(), "name") + self.assertFalse(ok) + + def test_update_challenge_phase_config_file_field_missing_path(self): + yaml_text = """ +challenge_phases: + - codename: C1 + name: N +""" + with patch.object( + self.github, "get_data_from_path", return_value=yaml_text + ): + + class PhaseObj: + codename = "C1" + test_annotation = object() + challenge = object() + + ok = self.github.update_challenge_phase_config( + PhaseObj(), "test_annotation" + ) + self.assertFalse(ok) + + def test_update_challenge_phase_config_non_file_updates(self): + yaml_text = """ +challenge_phases: + - codename: C1 + name: Old +""" + with patch.object( + self.github, "get_data_from_path", return_value=yaml_text + ), patch.object( + self.github, "update_data_from_path", return_value={"ok": True} + ) as mock_update: + + class PhaseObj: + codename = "C1" + name = "New" + challenge = object() + + ok = self.github.update_challenge_phase_config(PhaseObj(), "name") + self.assertTrue(ok) + mock_update.assert_called_once() + + def test_update_challenge_phase_config_skips_if_unchanged(self): + yaml_text = """ +challenge_phases: + - codename: C1 + name: Same +""" + with patch.object( + self.github, "get_data_from_path", return_value=yaml_text + ): + + class PhaseObj: + codename = "C1" + name = "Same" + challenge = object() + + ok = self.github.update_challenge_phase_config(PhaseObj(), "name") + self.assertTrue(ok) + + def test_update_challenge_phase_config_file_field_updates_when_changed( + self, + ): + yaml_text = """ +challenge_phases: + - codename: C1 + name: N + test_annotation_file: path/ann.txt +""" + with patch.object( + self.github, + "get_data_from_path", + side_effect=[ + yaml_text, + "old", + ], + ), patch.object( + self.github, "update_data_from_path", return_value={"ok": True} + ) as mock_update: + + class FileLike: + def __init__(self, data): + self._data = data + + def read(self): + return self._data + + class PhaseObj: + codename = "C1" + test_annotation = FileLike(b"new") + challenge = object() + + ok = self.github.update_challenge_phase_config( + PhaseObj(), "test_annotation" + ) + self.assertTrue(ok) + mock_update.assert_called_once() + + +# Lightweight checks for sync config constants to ensure availability and shape +def test_github_sync_config_expected_keys(): + from challenges import github_sync_config as cfg + + assert isinstance(cfg.challenge_non_file_fields, list) + for key in [ + "title", + "published", + "image", + "evaluation_script", + "start_date", + "end_date", + ]: + assert key in cfg.challenge_non_file_fields + + assert isinstance(cfg.challenge_file_fields, list) + for key in [ + "description", + "evaluation_details", + "terms_and_conditions", + "submission_guidelines", + ]: + assert key in cfg.challenge_file_fields + + assert isinstance(cfg.challenge_phase_non_file_fields, list) + assert "name" in cfg.challenge_phase_non_file_fields + assert "codename" in cfg.challenge_phase_non_file_fields + + assert isinstance(cfg.challenge_phase_file_fields, list) + assert "description" in cfg.challenge_phase_file_fields + + assert isinstance(cfg.challenge_additional_sections, list) + for key in ["leaderboard", "dataset_splits", "challenge_phase_splits"]: + assert key in cfg.challenge_additional_sections + + +class TestGithubSync(TestCase): + """Test cases for GitHub sync functionality""" + + def setUp(self): + # Create a test challenge with GitHub configuration + self.user = User.objects.create( + username="owner", email="o@example.com" + ) + self.host_team = ChallengeHostTeam.objects.create( + team_name="team", created_by=self.user + ) + self.challenge = Challenge.objects.create( + title="Test Challenge", + description="Test Description", + github_token="test_token", + github_repository="test/repo", + github_branch="master", + creator=self.host_team, + start_date=timezone.now(), + end_date=timezone.now() + timezone.timedelta(days=30), + ) + + # Create a test challenge phase + self.challenge_phase = ChallengePhase.objects.create( + name="Test Phase", + description="Test Phase Description", + challenge=self.challenge, + codename="test_phase", + ) + + @patch("challenges.github_utils.GithubInterface") + def test_sync_challenge_to_github_success(self, mock_github_class): + """Test successful challenge sync to GitHub""" + mock_github = Mock() + mock_github_class.return_value = mock_github + mock_github.update_challenge_config.return_value = True + + github_challenge_sync(self.challenge.id, changed_field="title") + + mock_github_class.assert_called_once_with( + "test/repo", "master", "test_token" + ) + mock_github.update_challenge_config.assert_called_once() + + @patch("challenges.github_utils.GithubInterface") + def test_sync_challenge_returns_false_on_update_failure( + self, mock_github_class + ): + mock_github = Mock() + mock_github_class.return_value = mock_github + mock_github.update_challenge_config.return_value = False + + ok = gu.github_challenge_sync(self.challenge.id, changed_field="title") + self.assertFalse(ok) + + def test_sync_challenge_to_github_no_token(self): + """Test challenge sync when no GitHub token is configured""" + self.challenge.github_token = "" + with self.assertLogs(level=logging.WARNING): + github_challenge_sync(self.challenge.id, changed_field="title") + + def test_sync_challenge_to_github_no_repo(self): + """Test challenge sync when no GitHub repository is configured""" + self.challenge.github_repository = "" + with self.assertLogs(level=logging.WARNING): + github_challenge_sync(self.challenge.id, changed_field="title") + + @patch("challenges.github_utils.GithubInterface") + def test_sync_challenge_phase_to_github_success(self, mock_github_class): + """Test successful challenge phase sync to GitHub""" + mock_github = Mock() + mock_github_class.return_value = mock_github + mock_github.update_challenge_phase_config.return_value = True + + github_challenge_phase_sync( + self.challenge_phase.id, changed_field="name" + ) + + mock_github_class.assert_called_once_with( + "test/repo", "master", "test_token" + ) + mock_github.update_challenge_phase_config.assert_called_once() + + def test_sync_challenge_phase_to_github_no_token(self): + """Test challenge phase sync when no GitHub token is configured""" + self.challenge.github_token = "" + with self.assertLogs(level=logging.WARNING): + github_challenge_phase_sync( + self.challenge_phase.id, changed_field="name" + ) + + def test_github_sync_invalid_changed_field_type_and_not_found(self): + # invalid changed_field should return False and cleanup flags + ok = gu.github_challenge_sync(self.challenge.id, changed_field=123) + self.assertFalse(ok) + # flags are cleaned + self.assertFalse(hasattr(gu._github_sync_context, "skip_github_sync")) + self.assertFalse(hasattr(gu._github_sync_context, "change_source")) + + # not found + ok2 = gu.github_challenge_sync(999999, changed_field="title") + self.assertFalse(ok2) + + @patch("challenges.github_utils.GithubInterface") + def test_sync_challenge_phase_returns_false_on_update_failure( + self, mock_github_class + ): + mock_github = Mock() + mock_github_class.return_value = mock_github + mock_github.update_challenge_phase_config.return_value = False + + ok = gu.github_challenge_phase_sync( + self.challenge_phase.id, changed_field="name" + ) + self.assertFalse(ok) + + def test_github_phase_sync_invalid_changed_field_type_and_not_found(self): + # invalid changed_field type + ok = gu.github_challenge_phase_sync( + self.challenge_phase.id, changed_field=[] + ) + self.assertFalse(ok) + self.assertFalse(hasattr(gu._github_sync_context, "skip_github_sync")) + self.assertFalse(hasattr(gu._github_sync_context, "change_source")) + + # not found + ok2 = gu.github_challenge_phase_sync(999999, changed_field="name") + self.assertFalse(ok2) + + def test_sync_challenge_phase_to_github_no_repo(self): + """Test challenge phase sync when no GitHub repository is configured""" + self.challenge.github_repository = "" + with self.assertLogs(level=logging.WARNING): + github_challenge_phase_sync( + self.challenge_phase.id, changed_field="name" + ) + + @patch("challenges.github_utils.GithubInterface") + def test_sync_challenge_calls_update(self, mock_github_class): + """Basic check that challenge sync invokes update method""" + mock_github = Mock() + mock_github_class.return_value = mock_github + + github_challenge_sync(self.challenge.id, changed_field="title") + mock_github.update_challenge_config.assert_called_once() + + @patch("challenges.github_utils.GithubInterface") + def test_sync_challenge_phase_calls_update(self, mock_github_class): + """Basic check that challenge phase sync invokes update method""" + mock_github = Mock() + mock_github_class.return_value = mock_github + + github_challenge_phase_sync( + self.challenge_phase.id, changed_field="name" + ) + mock_github.update_challenge_phase_config.assert_called_once() diff --git a/tests/unit/challenges/test_models.py b/tests/unit/challenges/test_models.py index fef437059f..73ddae843a 100644 --- a/tests/unit/challenges/test_models.py +++ b/tests/unit/challenges/test_models.py @@ -8,8 +8,11 @@ ChallengePhase, ChallengePhaseSplit, DatasetSplit, + GitHubSyncMiddleware, Leaderboard, LeaderboardData, + _infer_changed_field_from_request, + reset_github_sync_context, ) from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile @@ -261,3 +264,41 @@ def test__str__(self): "{0} : {1}".format(self.challenge_phase_split, self.submission), self.leaderboard_data.__str__(), ) + + +class GitHubSyncMiddlewareTests(BaseTestCase): + def test_middleware_captures_post_keys_and_infer_from_request(self): + reset_github_sync_context() + + class _Req: + method = "POST" + body = b"" + POST = {"title": "T", "created_at": "ignore"} + + mw = GitHubSyncMiddleware() + mw.process_request(_Req()) + + # An object that has attribute 'title' should be inferred as changed + class Obj: # minimal stub with attribute present + title = "T" + + inferred = _infer_changed_field_from_request(Obj()) + self.assertEqual(inferred, "title") + + def test_reset_github_sync_context_clears_payload_keys(self): + # seed payload keys via middleware + class _Req: + method = "PATCH" + body = b'{\n "name": "X"\n}' + + mw = GitHubSyncMiddleware() + mw.process_request(_Req()) + + # now reset and confirm inference returns None + reset_github_sync_context() + + class Obj: + name = "X" + + inferred = _infer_changed_field_from_request(Obj()) + self.assertIsNone(inferred) diff --git a/tests/unit/challenges/test_serializers.py b/tests/unit/challenges/test_serializers.py index fb83b93972..ae6c32bf3b 100644 --- a/tests/unit/challenges/test_serializers.py +++ b/tests/unit/challenges/test_serializers.py @@ -6,16 +6,30 @@ import pytest from allauth.account.models import EmailAddress -from challenges.models import Challenge, ChallengePhase +from challenges.models import ( + Challenge, + ChallengePhase, + DatasetSplit, + Leaderboard, +) from challenges.serializers import ( + ChallengeConfigSerializer, ChallengePhaseCreateSerializer, ChallengePhaseSerializer, + ChallengeSerializer, + DatasetSplitSerializer, + LeaderboardDataSerializer, + LeaderboardSerializer, PWCChallengeLeaderboardSerializer, + StarChallengeSerializer, UserInvitationSerializer, + ZipChallengePhaseSplitSerializer, + ZipChallengeSerializer, ) from challenges.utils import add_sponsors_to_challenge from django.contrib.auth.models import User from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase as DjangoTestCase from django.utils import timezone from hosts.models import ChallengeHost, ChallengeHostTeam from participants.models import ParticipantTeam @@ -204,6 +218,13 @@ def setUp(self): instance=self.challenge_phase ) + def test_phase_create_serializer_exclude_fields(self): + ser = ChallengePhaseCreateSerializer( + instance=self.challenge_phase, + context={"exclude_fields": ["slug", "nonexistent"]}, + ) + self.assertNotIn("slug", ser.fields) + def test_challenge_phase_create_serializer(self): data = self.challenge_phase_create_serializer.data @@ -486,6 +507,155 @@ def test_get_leaderboard(self): self.assertEqual(result, ["accuracy", "loss", "f1_score"]) +class DatasetSplitSerializerInitTests(DjangoTestCase): + def test_dataset_split_serializer_sets_config_id(self): + ser = DatasetSplitSerializer(data={}, context={"config_id": 7}) + self.assertEqual(ser.initial_data.get("config_id"), 7) + + +class ChallengeConfigAndLeaderboardSerializerInitTests(DjangoTestCase): + def test_challenge_config_serializer_sets_user(self): + user = User.objects.create(username="u3") + + class Req: + pass + + req = Req() + req.user = user + + ser = ChallengeConfigSerializer(data={}, context={"request": req}) + self.assertEqual(ser.initial_data.get("user"), user.pk) + + def test_leaderboard_serializer_sets_config_id(self): + ser = LeaderboardSerializer(data={}, context={"config_id": 5}) + self.assertEqual(ser.initial_data.get("config_id"), 5) + + +class ZipSerializersInitTests(DjangoTestCase): + def test_zip_challenge_serializer_sets_optional_fields_from_context(self): + img = object() + eval_script = object() + + class Req: + method = "GET" + + ser = ZipChallengeSerializer( + data={}, + context={ + "request": Req(), + "image": img, + "evaluation_script": eval_script, + "github_repository": "org/repo", + "github_branch": "main", + "github_token": "tok", + }, + ) + self.assertIs(ser.initial_data.get("image"), img) + self.assertIs(ser.initial_data.get("evaluation_script"), eval_script) + self.assertEqual(ser.initial_data.get("github_repository"), "org/repo") + self.assertEqual(ser.initial_data.get("github_branch"), "main") + self.assertEqual(ser.initial_data.get("github_token"), "tok") + + def test_zip_challenge_phase_split_serializer_excludes_fields(self): + ser = ZipChallengePhaseSplitSerializer( + instance=None, + context={"exclude_fields": ["leaderboard", "nonexistent"]}, + ) + self.assertNotIn("leaderboard", ser.fields) + + +class StarChallengeSerializerInitTests(DjangoTestCase): + def test_star_challenge_serializer_sets_fields_from_context(self): + user = User.objects.create(username="u6") + team = ChallengeHostTeam.objects.create( + team_name="t6", created_by=user + ) + chal = Challenge.objects.create(title="T6", creator=team) + + class Req: + pass + + req = Req() + req.user = user + + ser = StarChallengeSerializer( + data={}, + context={ + "challenge": chal, + "request": req, + "is_starred": True, + }, + ) + self.assertEqual(ser.initial_data.get("challenge"), chal.pk) + self.assertEqual(ser.initial_data.get("user"), user.pk) + self.assertTrue(ser.initial_data.get("is_starred")) + + +class LeaderboardDataSerializerInitTests(DjangoTestCase): + def test_leaderboard_data_serializer_sets_foreign_keys_from_context(self): + user = User.objects.create(username="u7") + team = ChallengeHostTeam.objects.create( + team_name="t7", created_by=user + ) + chal = Challenge.objects.create(title="T7", creator=team) + phase = ChallengePhase.objects.create( + name="P", description="d", challenge=chal + ) + ds = DatasetSplit.objects.create(name="N", codename="C") + lb = Leaderboard.objects.create(schema={}) + from challenges.models import ChallengePhaseSplit + + cps = ChallengePhaseSplit.objects.create( + challenge_phase=phase, dataset_split=ds, leaderboard=lb + ) + + class Sub: + pk = 101 + + ser = LeaderboardDataSerializer( + data={}, + context={"challenge_phase_split": cps, "submission": Sub()}, + ) + self.assertEqual(ser.initial_data.get("challenge_phase_split"), cps.pk) + self.assertEqual(ser.initial_data.get("submission"), 101) + + +class ChallengeSerializerInitTests(DjangoTestCase): + def test_challenge_serializer_sets_creator_and_token_on_non_get(self): + user = User.objects.create(username="u") + team = ChallengeHostTeam.objects.create(team_name="t", created_by=user) + + class Req: + pass + + req = Req() + req.method = "POST" + req.user = user + + context = { + "request": req, + "challenge_host_team": team, + "github_token": "tok", + } + data = {"title": "T"} + ser = ChallengeSerializer(data=data, context=context) + self.assertEqual(ser.initial_data.get("creator"), team.pk) + self.assertEqual(ser.initial_data.get("github_token"), "tok") + + def test_challenge_serializer_exposes_creator_field_on_get(self): + user = User.objects.create(username="u2") + team = ChallengeHostTeam.objects.create( + team_name="t2", created_by=user + ) + chal = Challenge.objects.create(title="TT", creator=team) + + class Req: + method = "GET" + + ser = ChallengeSerializer(instance=chal, context={"request": Req()}) + self.assertIn("creator", ser.fields) + + @pytest.mark.django_db class UserInvitationSerializerTests(TestCase): def setUp(self): diff --git a/tests/unit/challenges/test_urls.py b/tests/unit/challenges/test_urls.py index 96c89f214b..ed2ef2e628 100644 --- a/tests/unit/challenges/test_urls.py +++ b/tests/unit/challenges/test_urls.py @@ -124,7 +124,7 @@ def test_challenges_urls(self): ) self.assertEqual( url, - "/api/challenges/" + "/api/challenges/challenge/" + str(self.challenge.pk) + "/participant_team/team_detail", ) diff --git a/tests/unit/challenges/test_views.py b/tests/unit/challenges/test_views.py index ba543c7ae2..7392122768 100644 --- a/tests/unit/challenges/test_views.py +++ b/tests/unit/challenges/test_views.py @@ -206,6 +206,7 @@ def test_get_challenge(self): "sqs_retention_period": self.challenge.sqs_retention_period, "github_repository": self.challenge.github_repository, "github_branch": self.challenge.github_branch, + "github_token": self.challenge.github_token, } ] @@ -581,6 +582,7 @@ def test_get_particular_challenge(self): "sqs_retention_period": self.challenge.sqs_retention_period, "github_repository": self.challenge.github_repository, "github_branch": self.challenge.github_branch, + "github_token": self.challenge.github_token, } response = self.client.get(self.url, {}) self.assertEqual(response.data, expected) @@ -685,6 +687,7 @@ def test_update_challenge_when_user_is_its_creator(self): "sqs_retention_period": self.challenge.sqs_retention_period, "github_repository": self.challenge.github_repository, "github_branch": self.challenge.github_branch, + "github_token": self.challenge.github_token, } response = self.client.put( self.url, {"title": new_title, "description": new_description} @@ -815,6 +818,7 @@ def test_particular_challenge_partial_update(self): "sqs_retention_period": self.challenge.sqs_retention_period, "github_repository": self.challenge.github_repository, "github_branch": self.challenge.github_branch, + "github_token": self.challenge.github_token, } response = self.client.patch(self.url, self.partial_update_data) self.assertEqual(response.data, expected) @@ -894,6 +898,7 @@ def test_particular_challenge_update(self): "sqs_retention_period": self.challenge.sqs_retention_period, "github_repository": self.challenge.github_repository, "github_branch": self.challenge.github_branch, + "github_token": self.challenge.github_token, } response = self.client.put(self.url, self.data) self.assertEqual(response.data, expected) @@ -1492,6 +1497,7 @@ def test_get_past_challenges(self): "sqs_retention_period": self.challenge3.sqs_retention_period, "github_repository": self.challenge3.github_repository, "github_branch": self.challenge3.github_branch, + "github_token": self.challenge3.github_token, } ] response = self.client.get(self.url, {}, format="json") @@ -1577,6 +1583,7 @@ def test_get_present_challenges(self): "sqs_retention_period": self.challenge2.sqs_retention_period, "github_repository": self.challenge2.github_repository, "github_branch": self.challenge2.github_branch, + "github_token": self.challenge2.github_token, } ] response = self.client.get(self.url, {}, format="json") @@ -1662,6 +1669,7 @@ def test_get_future_challenges(self): "sqs_retention_period": self.challenge4.sqs_retention_period, "github_repository": self.challenge4.github_repository, "github_branch": self.challenge4.github_branch, + "github_token": self.challenge4.github_token, } ] response = self.client.get(self.url, {}, format="json") @@ -1747,6 +1755,7 @@ def test_get_all_challenges(self): "sqs_retention_period": self.challenge4.sqs_retention_period, "github_repository": self.challenge4.github_repository, "github_branch": self.challenge4.github_branch, + "github_token": self.challenge4.github_token, }, { "id": self.challenge3.pk, @@ -1816,6 +1825,7 @@ def test_get_all_challenges(self): "sqs_retention_period": self.challenge3.sqs_retention_period, "github_repository": self.challenge3.github_repository, "github_branch": self.challenge3.github_branch, + "github_token": self.challenge3.github_token, }, { "id": self.challenge2.pk, @@ -1885,6 +1895,7 @@ def test_get_all_challenges(self): "sqs_retention_period": self.challenge2.sqs_retention_period, "github_repository": self.challenge2.github_repository, "github_branch": self.challenge2.github_branch, + "github_token": self.challenge2.github_token, }, ] response = self.client.get(self.url, {}, format="json") @@ -2026,6 +2037,7 @@ def test_get_featured_challenges(self): "sqs_retention_period": self.challenge3.sqs_retention_period, "github_repository": self.challenge3.github_repository, "github_branch": self.challenge3.github_branch, + "github_token": self.challenge3.github_token, } ] response = self.client.get(self.url, {}, format="json") @@ -2192,6 +2204,7 @@ def test_get_challenge_by_pk_when_user_is_challenge_host(self): "sqs_retention_period": self.challenge3.sqs_retention_period, "github_repository": self.challenge3.github_repository, "github_branch": self.challenge3.github_branch, + "github_token": self.challenge3.github_token, } response = self.client.get(self.url, {}) @@ -2285,6 +2298,7 @@ def test_get_challenge_by_pk_when_user_is_participant(self): "sqs_retention_period": self.challenge4.sqs_retention_period, "github_repository": self.challenge4.github_repository, "github_branch": self.challenge4.github_branch, + "github_token": self.challenge4.github_token, } self.client.force_authenticate(user=self.user1) @@ -2440,6 +2454,7 @@ def test_get_challenge_when_host_team_is_given(self): "sqs_retention_period": self.challenge2.sqs_retention_period, "github_repository": self.challenge2.github_repository, "github_branch": self.challenge2.github_branch, + "github_token": self.challenge2.github_token, } ] @@ -2521,6 +2536,7 @@ def test_get_challenge_when_participant_team_is_given(self): "sqs_retention_period": self.challenge2.sqs_retention_period, "github_repository": self.challenge2.github_repository, "github_branch": self.challenge2.github_branch, + "github_token": self.challenge2.github_token, } ] @@ -2602,6 +2618,7 @@ def test_get_challenge_when_mode_is_participant(self): "sqs_retention_period": self.challenge2.sqs_retention_period, "github_repository": self.challenge2.github_repository, "github_branch": self.challenge2.github_branch, + "github_token": self.challenge2.github_token, } ] @@ -2681,6 +2698,7 @@ def test_get_challenge_when_mode_is_host(self): "sqs_retention_period": self.challenge.sqs_retention_period, "github_repository": self.challenge.github_repository, "github_branch": self.challenge.github_branch, + "github_token": self.challenge.github_token, }, { "id": self.challenge2.pk, @@ -2750,6 +2768,7 @@ def test_get_challenge_when_mode_is_host(self): "sqs_retention_period": self.challenge2.sqs_retention_period, "github_repository": self.challenge2.github_repository, "github_branch": self.challenge2.github_branch, + "github_token": self.challenge2.github_token, }, ] diff --git a/tests/unit/participants/test_views.py b/tests/unit/participants/test_views.py index eaa3327fd9..a731b97543 100644 --- a/tests/unit/participants/test_views.py +++ b/tests/unit/participants/test_views.py @@ -885,6 +885,7 @@ def test_get_teams_and_corresponding_challenges_for_a_participant(self): "sqs_retention_period": self.challenge1.sqs_retention_period, "github_repository": self.challenge1.github_repository, "github_branch": self.challenge1.github_branch, + "github_token": self.challenge1.github_token, }, "participant_team": { "id": self.participant_team.id, @@ -983,6 +984,7 @@ def test_get_participant_team_challenge_list(self): "sqs_retention_period": self.challenge1.sqs_retention_period, "github_repository": self.challenge1.github_repository, "github_branch": self.challenge1.github_branch, + "github_token": self.challenge1.github_token, } ]