From 47ac8db78472c27524c1934d3772270fdda4bb47 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Mon, 16 Dec 2024 17:15:56 -0500 Subject: [PATCH] ... --- breathecode/admissions/views.py | 2 +- breathecode/assessment/models.py | 4 +- breathecode/assessment/serializers.py | 1 + breathecode/assignments/actions.py | 17 +- .../commands/schedule_repository_deletions.py | 25 +++ .../send_deletion_order_notifications.py | 113 ++++++++++ breathecode/assignments/models.py | 2 + breathecode/assignments/tasks.py | 166 +++++++++----- .../tests_schedule_repository_deletions.py | 76 ++++--- ...tests_send_deletion_order_notifications.py | 204 ++++++++++++++++++ .../tasks/tests_async_learnpack_webhook.py | 84 ++++++++ ...s_send_repository_deletion_notification.py | 91 ++++++++ .../tests_set_cohort_user_assignments.py | 8 +- .../tasks/tests_student_task_notification.py | 10 +- .../tasks/tests_teacher_task_notification.py | 28 ++- breathecode/marketing/admin.py | 1 - breathecode/registry/models.py | 4 +- breathecode/registry/tasks.py | 8 +- 18 files changed, 733 insertions(+), 111 deletions(-) create mode 100644 breathecode/assignments/management/commands/send_deletion_order_notifications.py create mode 100644 breathecode/assignments/tests/management/commands/tests_send_deletion_order_notifications.py create mode 100644 breathecode/assignments/tests/tasks/tests_async_learnpack_webhook.py create mode 100644 breathecode/assignments/tests/tasks/tests_send_repository_deletion_notification.py diff --git a/breathecode/admissions/views.py b/breathecode/admissions/views.py index 01b8f557b..4ecc8af3c 100644 --- a/breathecode/admissions/views.py +++ b/breathecode/admissions/views.py @@ -1836,7 +1836,7 @@ def get(self, request, syllabus_id, version, academy_id): week_number = math.ceil(cumulative_days / class_days_per_week) if "technologies" not in day: day["technologies"] = [] - + if lang == "es": writer.writerow( [ diff --git a/breathecode/assessment/models.py b/breathecode/assessment/models.py index dd945236f..0753d583e 100644 --- a/breathecode/assessment/models.py +++ b/breathecode/assessment/models.py @@ -300,15 +300,13 @@ def save(self, *args, **kwargs): self.token = binascii.hexlify(os.urandom(20)).decode() _instance = super().save(*args, **kwargs) - + # Answer is being closed if is_creating or self.status != self._old_status: signals.userassessment_status_updated.send_robust(instance=self, sender=self.__class__) return _instance - - def get_score(self): total_score = 0 diff --git a/breathecode/assessment/serializers.py b/breathecode/assessment/serializers.py index d2acfacc9..20b7ec0b6 100644 --- a/breathecode/assessment/serializers.py +++ b/breathecode/assessment/serializers.py @@ -170,6 +170,7 @@ class HookUserAssessmentSerializer(serpy.Serializer): created_at = serpy.Field() summary = serpy.MethodField() + def get_summary(self, obj): total_score, last_one = obj.get_score() diff --git a/breathecode/assignments/actions.py b/breathecode/assignments/actions.py index c296c71b2..05dff1dfa 100644 --- a/breathecode/assignments/actions.py +++ b/breathecode/assignments/actions.py @@ -2,9 +2,10 @@ import os import requests +from capyc.rest_framework.exceptions import ValidationException +from task_manager.core.exceptions import AbortTask from breathecode.admissions.models import CohortUser -from capyc.rest_framework.exceptions import ValidationException from .models import Task @@ -134,19 +135,11 @@ def sync_cohort_tasks(cohort): return synchronized -def task_is_valid_for_notifications(task: Task) -> bool: - if not task: - logger.error("Task not found") - return False - +def validate_task_for_notifications(task: Task) -> bool: if not task.cohort: - logger.error("Can't determine the student cohort") - return False + raise AbortTask("Can't determine the student cohort") language = task.cohort.language.lower() if language not in NOTIFICATION_STRINGS: - logger.error(f"The language {language} is not implemented in teacher_task_notification") - return False - - return True + raise AbortTask(f"The language {language} is not implemented in teacher_task_notification") diff --git a/breathecode/assignments/management/commands/schedule_repository_deletions.py b/breathecode/assignments/management/commands/schedule_repository_deletions.py index 2351e8cb2..f61385773 100644 --- a/breathecode/assignments/management/commands/schedule_repository_deletions.py +++ b/breathecode/assignments/management/commands/schedule_repository_deletions.py @@ -8,6 +8,7 @@ from django.db.models import Q from django.utils import timezone +from breathecode.assignments import tasks from breathecode.assignments.models import RepositoryDeletionOrder, RepositoryWhiteList, Task from breathecode.authenticate.models import AcademyAuthSettings from breathecode.monitoring.models import RepositorySubscription @@ -18,8 +19,10 @@ class Command(BaseCommand): help = "Clean data from marketing module" github_url_pattern = re.compile(r"https?://github\.com/(?P[^/\s]+)/(?P[^/\s]+)/?") + allowed_users = ["breatheco-de", "4GeeksAcademy", "4geeksacademy"] def handle(self, *args, **options): + self.fill_whitelist() self.purge_deletion_orders() self.github() @@ -135,6 +138,11 @@ def purge_deletion_orders(self): break for deletion_order in qs: + if deletion_order.repository_user not in self.allowed_users: + to_delete.append(deletion_order.id) + print("here") + continue + if RepositoryWhiteList.objects.filter( provider=deletion_order.provider, repository_user__iexact=deletion_order.repository_user, @@ -158,6 +166,7 @@ def delete_github_repositories(self): status=RepositoryDeletionOrder.Status.PENDING, created_at__lte=timezone.now() - relativedelta(months=2), ), + repository_user__in=self.allowed_users, provider=RepositoryDeletionOrder.Provider.GITHUB, )[:100] @@ -291,6 +300,7 @@ def collect_transferred_orders(self): while True: qs = RepositoryDeletionOrder.objects.filter( + repository_user__in=self.allowed_users, provider=RepositoryDeletionOrder.Provider.GITHUB, status=RepositoryDeletionOrder.Status.TRANSFERRING, created_at__gt=timezone.now(), @@ -321,28 +331,43 @@ def transfer_ownership(self): while True: qs = RepositoryDeletionOrder.objects.filter( + repository_user__in=self.allowed_users, provider=RepositoryDeletionOrder.Provider.GITHUB, status=RepositoryDeletionOrder.Status.PENDING, created_at__gt=timezone.now(), ).exclude(id__in=ids)[:100] + print(-1111) if qs.count() == 0: break + print(2222) + for deletion_order in qs: ids.append(deletion_order.id) + try: + print(1111) if self.github_client.repo_exists( owner=deletion_order.repository_user, repo=deletion_order.repository_name ): + print(3333) new_owner = self.get_username(deletion_order.repository_user, deletion_order.repository_name) if not new_owner: continue + print(4444) + self.github_client.transfer_repo(repo=deletion_order.repository_name, new_owner=new_owner) deletion_order.status = RepositoryDeletionOrder.Status.TRANSFERRING deletion_order.save() + print(5555) + + tasks.send_repository_deletion_notification.delay(deletion_order.id, new_owner) + + print(6666, tasks.send_repository_deletion_notification.delay) + except Exception as e: deletion_order.status = RepositoryDeletionOrder.Status.ERROR deletion_order.status_text = str(e) diff --git a/breathecode/assignments/management/commands/send_deletion_order_notifications.py b/breathecode/assignments/management/commands/send_deletion_order_notifications.py new file mode 100644 index 000000000..02a4dc4cb --- /dev/null +++ b/breathecode/assignments/management/commands/send_deletion_order_notifications.py @@ -0,0 +1,113 @@ +import re +from typing import Any, Optional + +from django.core.management.base import BaseCommand + +from breathecode.assignments import tasks +from breathecode.assignments.models import RepositoryDeletionOrder +from breathecode.authenticate.models import AcademyAuthSettings +from breathecode.services.github import Github + + +class Command(BaseCommand): + help = "Clean data from marketing module" + github_url_pattern = re.compile(r"https?://github\.com/(?P[^/\s]+)/(?P[^/\s]+)/?") + + def handle(self, *args, **options): + self.github() + + def github(self): + processed = set() + for settings in AcademyAuthSettings.objects.filter( + github_owner__isnull=False, github_owner__credentialsgithub__isnull=False + ).exclude(github_username=""): + self.github_client = Github( + org=settings.github_username, token=settings.github_owner.credentialsgithub.token + ) + + key = (settings.github_username, settings.github_owner.id) + if key in processed: + continue + + processed.add(key) + allowed_users = ["breatheco-de", "4GeeksAcademy", "4geeksacademy"] + + items = RepositoryDeletionOrder.objects.filter(provider=RepositoryDeletionOrder.Provider.GITHUB) + for deletion_order in items: + if deletion_order.repository_user not in allowed_users: + continue + + new_owner = self.get_username(deletion_order.repository_user, deletion_order.repository_name) + if new_owner is None: + continue + + tasks.send_repository_deletion_notification.delay(deletion_order.id, new_owner) + + def check_path(self, obj: dict, *indexes: str) -> bool: + try: + value = obj + for index in indexes: + value = value[index] + return True + except Exception: + return False + + def how_many_added_members(self, events: list[dict[str, Any]]) -> int: + return len( + [ + event + for event in events + if self.check_path(event, "type") + and self.check_path(event, "payload", "action") + and event["type"] == "MemberEvent" + and event["payload"]["action"] == "added" + ] + ) + + def get_username(self, owner: str, repo: str) -> Optional[str]: + r = repo + repo = repo.lower() + index = -1 + for events in self.github_client.get_repo_events(owner, r): + index += 1 + for event in events: + if self.check_path(event, "type") is False: + continue + + if ( + index == 0 + and event["type"] == "MemberEvent" + and len(events) < 30 + and self.check_path(event, "payload", "action") + and self.how_many_added_members(events) == 1 + and self.check_path(event, "payload", "member", "login") + and event["payload"]["action"] == "added" + ): + return event["payload"]["member"]["login"] + + if ( + event["type"] == "watchEvent" + and self.check_path(event, "actor", "login") + and event["actor"]["login"].replace("-", "").lower() in repo + ): + return event["actor"]["login"] + + if ( + event["type"] == "MemberEvent" + and self.check_path(event, "payload", "member", "login") + and event["payload"]["member"]["login"].replace("-", "").lower() in repo + ): + return event["payload"]["member"]["login"] + + if ( + event["type"] == "IssuesEvent" + and self.check_path(event, "payload", "assignee", "login") + and event["payload"]["assignee"]["login"].replace("-", "").lower() in repo + ): + return event["payload"]["assignee"]["login"] + + if ( + self.check_path(event, "actor", "login") + and event["actor"]["login"].replace("-", "").lower() in repo + ): + return event["actor"]["login"] diff --git a/breathecode/assignments/models.py b/breathecode/assignments/models.py index 142588d82..bd9d47417 100644 --- a/breathecode/assignments/models.py +++ b/breathecode/assignments/models.py @@ -271,6 +271,8 @@ def __init__(self, *args, **kwargs): repository_user = models.CharField(max_length=256) repository_name = models.CharField(max_length=256) + notified_at = models.DateTimeField(default=None, null=True, blank=True) + starts_transferring_at = models.DateTimeField(default=None, null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) diff --git a/breathecode/assignments/tasks.py b/breathecode/assignments/tasks.py index 7cf9040cf..5bcab45db 100644 --- a/breathecode/assignments/tasks.py +++ b/breathecode/assignments/tasks.py @@ -1,31 +1,40 @@ import logging import os import re +from typing import Any -from celery import shared_task +from capyc.core.i18n import translation +from django.contrib.auth.models import User +from django.utils import timezone from linked_services.django.service import Service +from task_manager.core.exceptions import AbortTask, RetryTask +from task_manager.django.decorators import task import breathecode.notify.actions as actions -from breathecode.services.learnpack import LearnPack from breathecode.admissions.models import CohortUser +from breathecode.assignments.actions import NOTIFICATION_STRINGS, validate_task_for_notifications from breathecode.assignments.models import LearnPackWebhook -from breathecode.assignments.actions import NOTIFICATION_STRINGS, task_is_valid_for_notifications +from breathecode.authenticate.actions import get_user_settings +from breathecode.notify import actions as notify_actions +from breathecode.services.learnpack import LearnPack from breathecode.utils import TaskPriority -from .models import Task +from .models import RepositoryDeletionOrder, Task # Get an instance of a logger logger = logging.getLogger(__name__) -@shared_task(bind=True, priority=TaskPriority.NOTIFICATION.value) -def student_task_notification(self, task_id): +@task(bind=True, priority=TaskPriority.NOTIFICATION.value) +def student_task_notification(self, task_id, **_: Any): """Notify if the task was change.""" logger.info("Starting student_task_notification") task = Task.objects.filter(id=task_id).first() - if not task_is_valid_for_notifications(task): - return + if task is None: + raise RetryTask("Task not found") + + validate_task_for_notifications(task) language = task.cohort.language.lower() revision_status = task.revision_status @@ -47,35 +56,29 @@ def student_task_notification(self, task_id): ) -@shared_task(bind=True, priority=TaskPriority.ACTIVITY.value) -def async_learnpack_webhook(self, webhook_id): - logger.debug("Starting async_learnpack_webhook") - status = "ok" +@task(bind=True, priority=TaskPriority.ACTIVITY.value) +def async_learnpack_webhook(self, webhook_id, **_: Any): + logger.info(f"Starting async_learnpack_webhook for webhook {webhook_id}") webhook = LearnPackWebhook.objects.filter(id=webhook_id).first() - if webhook: - try: - client = LearnPack() - client.execute_action(webhook_id) - except Exception as e: - logger.debug("LearnPack Telemetry exception") - logger.debug(str(e)) - status = "error" - - else: + if webhook is None: message = f"Webhook {webhook_id} not found" - webhook.status = "ERROR" - webhook.status_text = message - webhook.save() - logger.debug(message) - status = "error" + raise RetryTask(message) - logger.debug(f"LearnPack telemetry status: {status}") + try: + client = LearnPack() + client.execute_action(webhook_id) + + except Exception as e: + webhook.status = "ERROR" + webhook.status_text = str(e) + webhook.save() + raise e -@shared_task(bind=True, priority=TaskPriority.NOTIFICATION.value) -def teacher_task_notification(self, task_id): +@task(bind=True, priority=TaskPriority.NOTIFICATION.value) +def teacher_task_notification(self, task_id, **_: Any): """Notify if the task was change.""" logger.info("Starting teacher_task_notification") @@ -88,8 +91,10 @@ def teacher_task_notification(self, task_id): url = re.sub("/$", "", url) task = Task.objects.filter(id=task_id).first() - if not task_is_valid_for_notifications(task): - return + if task is None: + raise RetryTask("Task not found") + + validate_task_for_notifications(task) language = task.cohort.language.lower() subject = NOTIFICATION_STRINGS[language]["teacher"]["subject"].format( @@ -118,8 +123,8 @@ def teacher_task_notification(self, task_id): ) -@shared_task(bind=False, priority=TaskPriority.ACADEMY.value) -def set_cohort_user_assignments(task_id: int): +@task(bind=False, priority=TaskPriority.ACADEMY.value) +def set_cohort_user_assignments(task_id: int, **_: Any): logger.info("Executing set_cohort_user_assignments") def serialize_task(task): @@ -131,14 +136,12 @@ def serialize_task(task): task = Task.objects.filter(id=task_id).first() if not task: - logger.error("Task not found") - return + raise AbortTask("Task not found") cohort_user = CohortUser.objects.filter(cohort=task.cohort, user=task.user, role="STUDENT").first() if not cohort_user: - logger.error("CohortUser not found") - return + raise AbortTask("CohortUser not found") user_history_log = cohort_user.history_log or {} user_history_log["delivered_assignments"] = user_history_log.get("delivered_assignments", []) @@ -158,6 +161,7 @@ def serialize_task(task): cohort_user.history_log = user_history_log cohort_user.save() + logger.info("History log saved") s = None try: @@ -187,13 +191,11 @@ def serialize_task(task): task.rigobot_repository_id = data["id"] except Exception as e: - logger.error(str(e)) + raise AbortTask(str(e)) - logger.info("History log saved") - -@shared_task(bind=False, priority=TaskPriority.ACADEMY.value) -def sync_cohort_user_tasks(cohort_user_id: int): +@task(bind=False, priority=TaskPriority.ACADEMY.value) +def sync_cohort_user_tasks(cohort_user_id: int, **_: Any): logger.info(f"Executing sync_cohort_user_tasks for cohort user {cohort_user_id}") cohort_user = CohortUser.objects.filter(id=cohort_user_id).first() @@ -234,19 +236,87 @@ def parse_task(type, assignment): for r in answers: all_cohort_tasks.append(parse_task("QUIZ", r)) - for task in all_cohort_tasks: + for cohort_task in all_cohort_tasks: user_task = Task.objects.filter( - user=cohort_user.user, cohort=cohort, associated_slug=task["associated_slug"], task_type=task["task_type"] + user=cohort_user.user, + cohort=cohort, + associated_slug=cohort_task["associated_slug"], + task_type=cohort_task["task_type"], ).first() if user_task is None: user_task = Task( user=cohort_user.user, cohort=cohort, - associated_slug=task["associated_slug"], - title=task["title"], - task_type=task["task_type"], + associated_slug=cohort_task["associated_slug"], + title=cohort_task["title"], + task_type=cohort_task["task_type"], ) user_task.save() logger.info(f"Cohort User {cohort_user_id} synced successfully") + + +@task(bind=False, priority=TaskPriority.ACADEMY.value) +def send_repository_deletion_notification(deletion_order_id: int, new_owner: str, **_: Any): + logger.info(f"Executing send_repository_deletion_notification for cohort user {deletion_order_id}") + deletion_order = RepositoryDeletionOrder.objects.filter( + id=deletion_order_id, status=RepositoryDeletionOrder.Status.TRANSFERRING, notified_at=None + ).first() + + if deletion_order is None: + raise RetryTask("Repository deletion order not found") + + if not new_owner: + raise AbortTask("New owner not found") + + user = None + link = None + + if deletion_order.provider == RepositoryDeletionOrder.Provider.GITHUB: + user = User.objects.filter(credentialsgithub__username=new_owner).first() + link = f"https://github.com/{deletion_order.repository_user}/{deletion_order.repository_name}" + else: + raise AbortTask(f"Provider {deletion_order.provider} not supported") + + if user is None: + raise AbortTask(f"User not found for {RepositoryDeletionOrder.Provider.GITHUB} username {new_owner}") + + settings = get_user_settings(user.id) + lang = settings.lang + + print(f"lang: {lang}") + + subject = translation( + lang, + en=f"We are transfering the repository {deletion_order.repository_name} to you", + es=f"Te estamos transfiriendo el repositorio {deletion_order.repository_name}", + ) + + message = translation( + lang, + en=f"We are transfering the repository {deletion_order.repository_name} to you, you have two " + "months to accept the transfer before we delete it", + es=f"Te estamos transfiriendo el repositorio {deletion_order.repository_name}, tienes dos meses " + "para aceptar la transferencia antes de que la eliminemos", + ) + + button = translation( + lang, + en="Go to the repository", + es="Ir al repositorio", + ) + + notify_actions.send_email_message( + "message", + user.email, + { + "SUBJECT": subject, + "MESSAGE": message, + "BUTTON": button, + "LINK": link, + }, + ) + + deletion_order.notified_at = timezone.now() + deletion_order.save() diff --git a/breathecode/assignments/tests/management/commands/tests_schedule_repository_deletions.py b/breathecode/assignments/tests/management/commands/tests_schedule_repository_deletions.py index 2c955d130..be17f0259 100644 --- a/breathecode/assignments/tests/management/commands/tests_schedule_repository_deletions.py +++ b/breathecode/assignments/tests/management/commands/tests_schedule_repository_deletions.py @@ -3,20 +3,22 @@ """ from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, call import capyc.pytest as capyc import pytest from dateutil.relativedelta import relativedelta from linked_services.django.actions import reset_app_cache +from breathecode.assignments import tasks from breathecode.assignments.management.commands.schedule_repository_deletions import Command from breathecode.registry.models import Asset @pytest.fixture(autouse=True) -def setup(db): +def setup(db, monkeypatch: pytest.MonkeyPatch): reset_app_cache() + monkeypatch.setattr("breathecode.assignments.tasks.send_repository_deletion_notification.delay", MagicMock()) yield @@ -95,6 +97,7 @@ def test_no_settings(database: capyc.Database): assert database.list_of("assignments.RepositoryDeletionOrder") == [] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_no_repos(database: capyc.Database, patch_get): @@ -115,6 +118,7 @@ def test_no_repos(database: capyc.Database, patch_get): assert database.list_of("assignments.RepositoryDeletionOrder") == [] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_two_repos(database: capyc.Database, patch_get): @@ -159,6 +163,7 @@ def test_two_repos(database: capyc.Database, patch_get): "status": "PENDING", "status_text": None, "starts_transferring_at": None, + "notified_at": None, }, { "id": 2, @@ -168,9 +173,11 @@ def test_two_repos(database: capyc.Database, patch_get): "status": "PENDING", "status_text": None, "starts_transferring_at": None, + "notified_at": None, }, ] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_two_repos__deleting_repositories(database: capyc.Database, patch_get, set_datetime, utc_now): @@ -269,6 +276,7 @@ def test_two_repos__deleting_repositories(database: capyc.Database, patch_get, s "status": "DELETED", "status_text": None, "starts_transferring_at": None, + "notified_at": None, }, { "id": 2, @@ -278,9 +286,11 @@ def test_two_repos__deleting_repositories(database: capyc.Database, patch_get, s "status": "DELETED", "status_text": None, "starts_transferring_at": None, + "notified_at": None, }, ] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_two_repos__repository_transferred(database: capyc.Database, patch_get, set_datetime, utc_now): @@ -351,6 +361,7 @@ def test_two_repos__repository_transferred(database: capyc.Database, patch_get, "status": "TRANSFERRED", "status_text": None, "starts_transferring_at": utc_now, + "notified_at": None, }, { "id": 2, @@ -360,9 +371,11 @@ def test_two_repos__repository_transferred(database: capyc.Database, patch_get, "status": "TRANSFERRED", "status_text": None, "starts_transferring_at": utc_now, + "notified_at": None, }, ] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_two_repos__repository_does_not_exists(database: capyc.Database, patch_get, set_datetime, utc_now): @@ -433,6 +446,7 @@ def test_two_repos__repository_does_not_exists(database: capyc.Database, patch_g "status": "ERROR", "status_text": "Repository does not exist: breatheco-de/curso-nodejs-4geeks", "starts_transferring_at": None, + "notified_at": None, }, { "id": 2, @@ -442,15 +456,17 @@ def test_two_repos__repository_does_not_exists(database: capyc.Database, patch_g "status": "ERROR", "status_text": "Repository does not exist: 4GeeksAcademy/curso-nodejs-4geeks", "starts_transferring_at": None, + "notified_at": None, }, ] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_one_repo__pending__user_not_found(database: capyc.Database, patch_get, set_datetime, utc_now, fake): delta = relativedelta(months=2, hours=1) - github_username = fake.slug() + github_username = "4GeeksAcademy" model = database.create( academy_auth_settings={"github_username": github_username}, city=1, @@ -508,23 +524,27 @@ def test_one_repo__pending__user_not_found(database: capyc.Database, patch_get, "status": "PENDING", "status_text": None, "starts_transferring_at": None, + "notified_at": None, }, ] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] @pytest.mark.parametrize( "event", [ - Event.push("my-user"), - Event.member("my-user"), - Event.watch("my-user"), + Event.push("breatheco-de"), + Event.member("breatheco-de"), + Event.watch("breatheco-de"), ], ) -def test_one_repo__pending__user_found(database: capyc.Database, patch_get, set_datetime, utc_now, event): +def test_one_repo__pending__user_found( + database: capyc.Database, format: capyc.Format, patch_get, set_datetime, utc_now, event +): delta = relativedelta(months=2, hours=1) - github_username = "my-user" + github_username = "breatheco-de" parsed_name = github_username.replace("-", "") model = database.create( academy_auth_settings={"github_username": github_username}, @@ -590,24 +610,23 @@ def test_one_repo__pending__user_found(database: capyc.Database, patch_get, set_ assert database.list_of("assignments.RepositoryDeletionOrder") == [ { - "id": 1, - "provider": "GITHUB", - "repository_name": f"curso-nodejs-4geeks-{parsed_name}", - "repository_user": github_username, + **format.to_obj_repr(model.repository_deletion_order), "status": "TRANSFERRING", - "status_text": None, "starts_transferring_at": utc_now - delta, - }, + "notified_at": None, + } ] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [ + call(1, "breatheco-de"), + ] def test_one_repo__pending__user_found__inferred(database: capyc.Database, patch_get, set_datetime, utc_now): - event = Event.member("my-user") + event = Event.member("4GeeksAcademy") delta = relativedelta(months=2, hours=1) - github_username = "my-user" - parsed_name = github_username.replace("-", "") + github_username = "4GeeksAcademy" model = database.create( academy_auth_settings={"github_username": github_username}, city=1, @@ -679,17 +698,18 @@ def test_one_repo__pending__user_found__inferred(database: capyc.Database, patch "status": "TRANSFERRING", "status_text": None, "starts_transferring_at": utc_now - delta, + "notified_at": None, }, ] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [ + call(1, "4GeeksAcademy"), + ] def test_one_repo__transferring__repo_found(database: capyc.Database, patch_get, set_datetime, utc_now): - event = Event.member("my-user") - delta = relativedelta(months=2, hours=1) - github_username = "my-user" - parsed_name = github_username.replace("-", "") + github_username = "breatheco-de" model = database.create( academy_auth_settings={"github_username": github_username}, city=1, @@ -747,16 +767,16 @@ def test_one_repo__transferring__repo_found(database: capyc.Database, patch_get, "status": "TRANSFERRING", "status_text": None, "starts_transferring_at": utc_now, + "notified_at": None, }, ] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_one_repo__transferring__repo_not_found(database: capyc.Database, patch_get, set_datetime, utc_now): - event = Event.member("my-user") - delta = relativedelta(months=2, hours=1) - github_username = "my-user" + github_username = "breatheco-de" model = database.create( academy_auth_settings={"github_username": github_username}, city=1, @@ -814,9 +834,11 @@ def test_one_repo__transferring__repo_not_found(database: capyc.Database, patch_ "status": "TRANSFERRED", "status_text": None, "starts_transferring_at": utc_now, + "notified_at": None, }, ] assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] # def test_two_repos__deleting_repositories__got_an_error(database: capyc.Database, patch_get, set_datetime, utc_now): @@ -979,6 +1001,7 @@ def test_two_repos_in_the_whitelist(database: capyc.Database, patch_get): "repository_user": "4GeeksAcademy", }, ] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_two_repos_scheduled_and_in_this_execution_was_added_to_the_whitelist(database: capyc.Database, patch_get): @@ -1063,6 +1086,7 @@ def test_two_repos_scheduled_and_in_this_execution_was_added_to_the_whitelist(da "repository_user": "4GeeksAcademy", }, ] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_two_repos_used_in_subscriptions(database: capyc.Database, patch_get): @@ -1127,6 +1151,7 @@ def test_two_repos_used_in_subscriptions(database: capyc.Database, patch_get): "repository_user": "4GeeksAcademy", }, ] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] def test_two_repos_scheduled_and_in_this_execution_was_added_to_the_subscriptions(database: capyc.Database, patch_get): @@ -1207,6 +1232,7 @@ def test_two_repos_scheduled_and_in_this_execution_was_added_to_the_subscription "repository_user": "4GeeksAcademy", }, ] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] @pytest.mark.parametrize( @@ -1298,6 +1324,7 @@ def test_two_repos_used_in_assets(database: capyc.Database, patch_get, attr, is_ "repository_user": "4GeeksAcademy", }, ] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] @pytest.mark.parametrize( @@ -1413,3 +1440,4 @@ def test_two_repos_scheduled_and_in_this_execution_was_added_to_the_assets( "repository_user": "4GeeksAcademy", }, ] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] diff --git a/breathecode/assignments/tests/management/commands/tests_send_deletion_order_notifications.py b/breathecode/assignments/tests/management/commands/tests_send_deletion_order_notifications.py new file mode 100644 index 000000000..51eaf6ea9 --- /dev/null +++ b/breathecode/assignments/tests/management/commands/tests_send_deletion_order_notifications.py @@ -0,0 +1,204 @@ +""" +Test /answer +""" + +from typing import Any +from unittest.mock import MagicMock, call + +import capyc.pytest as capyc +import pytest +from dateutil.relativedelta import relativedelta +from linked_services.django.actions import reset_app_cache + +from breathecode.assignments import tasks +from breathecode.assignments.management.commands.send_deletion_order_notifications import Command +from breathecode.registry.models import Asset + + +@pytest.fixture(autouse=True) +def setup(db, monkeypatch: pytest.MonkeyPatch): + reset_app_cache() + monkeypatch.setattr("breathecode.assignments.tasks.send_repository_deletion_notification.delay", MagicMock()) + yield + + +# https://api.github.com/repos/{org}/{repo}/events +class Event: + + @staticmethod + def push(login: str) -> dict[str, Any]: + return { + "type": "PushEvent", + "actor": { + "login": login, + }, + } + + @staticmethod + def member(login: str, action: str = "added") -> dict[str, Any]: + return { + "type": "MemberEvent", + "payload": { + "member": { + "login": login, + }, + "action": action, + }, + } + + @staticmethod + def watch(login: str) -> dict[str, Any]: + return { + "type": "WatchEvent", + "actor": { + "login": login, + }, + } + + +class ResponseMock: + + def __init__(self, data, status=200, headers={}): + self.content = data + self.status_code = status + self.headers = headers + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + pass + + def json(self): + return self.content + + +@pytest.fixture +def patch_get(monkeypatch): + + def handler(objs): + + def x(*args, **kwargs): + nonlocal objs + res = [obj for obj in objs if obj["url"] == kwargs["url"] and obj["method"] == kwargs["method"]] + + if len(res) == 0: + return ResponseMock({}, 404, {}) + return ResponseMock(res[0]["expected"], res[0]["code"], res[0]["headers"]) + + monkeypatch.setattr("requests.request", MagicMock(side_effect=x)) + + yield handler + + +def test_no_settings(database: capyc.Database): + command = Command() + command.handle() + + assert database.list_of("assignments.RepositoryDeletionOrder") == [] + assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [] + + +@pytest.mark.parametrize( + "event", + [ + Event.push("breatheco-de"), + Event.member("breatheco-de"), + Event.watch("breatheco-de"), + ], +) +def test_one_repo__pending__user_found( + database: capyc.Database, format: capyc.Format, patch_get, set_datetime, utc_now, event +): + + delta = relativedelta(months=2, hours=1) + github_username = "breatheco-de" + parsed_name = github_username.replace("-", "") + model = database.create( + academy_auth_settings={"github_username": github_username}, + city=1, + country=1, + user=1, + credentials_github=1, + repository_deletion_order=[ + { + "provider": "GITHUB", + "repository_name": f"curso-nodejs-4geeks-{parsed_name}", + "repository_user": github_username, + "status": "PENDING", + "status_text": None, + }, + ], + ) + set_datetime(utc_now - delta) + + patch_get( + [ + { + "method": "GET", + "url": f"https://api.github.com/orgs/{model.academy_auth_settings.github_username}/curso-nodejs-4geeks-{parsed_name}/events?page=1&per_page=30", + "expected": [event], + "code": 200, + "headers": {}, + }, + ] + ) + command = Command() + command.handle() + + assert database.list_of("assignments.RepositoryDeletionOrder") == [ + format.to_obj_repr(model.repository_deletion_order), + ] + assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [ + call(1, "breatheco-de"), + ] + + +def test_one_repo__pending__user_found__inferred( + database: capyc.Database, format: capyc.Format, patch_get, set_datetime, utc_now +): + event = Event.member("4GeeksAcademy") + + delta = relativedelta(months=2, hours=1) + github_username = "4GeeksAcademy" + model = database.create( + academy_auth_settings={"github_username": github_username}, + city=1, + country=1, + user=1, + credentials_github=1, + repository_deletion_order=[ + { + "provider": "GITHUB", + "repository_name": "curso-nodejs-4geeks", + "repository_user": github_username, + "status": "PENDING", + "status_text": None, + }, + ], + ) + set_datetime(utc_now - delta) + + patch_get( + [ + { + "method": "GET", + "url": f"https://api.github.com/orgs/{model.academy_auth_settings.github_username}/curso-nodejs-4geeks/events?page=1&per_page=30", + "expected": [event], + "code": 200, + "headers": {}, + }, + ] + ) + command = Command() + command.handle() + + assert database.list_of("assignments.RepositoryDeletionOrder") == [ + format.to_obj_repr(model.repository_deletion_order), + ] + assert database.list_of("assignments.RepositoryWhiteList") == [] + assert tasks.send_repository_deletion_notification.delay.call_args_list == [ + call(1, "4GeeksAcademy"), + ] diff --git a/breathecode/assignments/tests/tasks/tests_async_learnpack_webhook.py b/breathecode/assignments/tests/tasks/tests_async_learnpack_webhook.py new file mode 100644 index 000000000..d61c6ce18 --- /dev/null +++ b/breathecode/assignments/tests/tasks/tests_async_learnpack_webhook.py @@ -0,0 +1,84 @@ +""" +Test /answer +""" + +from logging import Logger +from unittest.mock import MagicMock, call + +import capyc.pytest as capy +import pytest +from linked_services.django.actions import reset_app_cache + +from breathecode.services.learnpack.client import LearnPack + +from ...tasks import async_learnpack_webhook + + +@pytest.fixture(autouse=True) +def x(db: None, monkeypatch: pytest.MonkeyPatch): + reset_app_cache() + + monkeypatch.setattr("logging.Logger.info", MagicMock()) + monkeypatch.setattr("logging.Logger.error", MagicMock()) + + yield + + +@pytest.fixture +def successful_webhook(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("breathecode.services.learnpack.client.LearnPack.execute_action", MagicMock()) + + +@pytest.fixture +def failed_webhook(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + "breathecode.services.learnpack.client.LearnPack.execute_action", MagicMock(side_effect=Exception("Error")) + ) + + +def test_no_webhook(database: capy.Database, successful_webhook: None): + async_learnpack_webhook.delay(1) + + assert Logger.info.call_args_list == [ + call("Starting async_learnpack_webhook for webhook 1"), + call("Starting async_learnpack_webhook for webhook 1"), + ] + assert Logger.error.call_args_list == [ + call("Webhook 1 not found", exc_info=True), + ] + assert database.list_of("assignments.LearnPackWebhook") == [] + assert LearnPack.execute_action.call_args_list == [] + + +def test_trigger_webhook(database: capy.Database, format: capy.Format, successful_webhook: None): + model = database.create(learn_pack_webhook=1) + Logger.info.reset_mock() + async_learnpack_webhook.delay(1) + + assert Logger.info.call_args_list == [ + call("Starting async_learnpack_webhook for webhook 1"), + ] + assert Logger.error.call_args_list == [] + assert database.list_of("assignments.LearnPackWebhook") == [format.to_obj_repr(model.learn_pack_webhook)] + assert LearnPack.execute_action.call_args_list == [call(1)] + + +def test_trigger_webhook_with_error(database: capy.Database, format: capy.Format, failed_webhook: None): + model = database.create(learn_pack_webhook=1) + Logger.info.reset_mock() + async_learnpack_webhook.delay(1) + + assert Logger.info.call_args_list == [ + call("Starting async_learnpack_webhook for webhook 1"), + ] + assert Logger.error.call_args_list == [ + call("Error", exc_info=True), + ] + assert database.list_of("assignments.LearnPackWebhook") == [ + { + **format.to_obj_repr(model.learn_pack_webhook), + "status": "ERROR", + "status_text": "Error", + } + ] + assert LearnPack.execute_action.call_args_list == [call(1)] diff --git a/breathecode/assignments/tests/tasks/tests_send_repository_deletion_notification.py b/breathecode/assignments/tests/tasks/tests_send_repository_deletion_notification.py new file mode 100644 index 000000000..580d099a2 --- /dev/null +++ b/breathecode/assignments/tests/tasks/tests_send_repository_deletion_notification.py @@ -0,0 +1,91 @@ +""" +Test /answer +""" + +from logging import Logger +from unittest.mock import MagicMock, call + +import capyc.pytest as capy +import pytest +from linked_services.django.actions import reset_app_cache + +from breathecode.notify import actions + +from ...tasks import send_repository_deletion_notification + + +@pytest.fixture(autouse=True) +def x(db, monkeypatch: pytest.MonkeyPatch): + empty = lambda *args, **kwargs: None + + reset_app_cache() + + monkeypatch.setattr("logging.Logger.info", MagicMock()) + monkeypatch.setattr("logging.Logger.error", MagicMock()) + + monkeypatch.setattr("breathecode.notify.actions.send_email_message", MagicMock()) + + yield + + +def test_no_order(database: capy.Database): + send_repository_deletion_notification.delay(1, "bob") + + assert Logger.info.call_args_list == [ + call("Executing send_repository_deletion_notification for cohort user 1"), + call("Executing send_repository_deletion_notification for cohort user 1"), + ] + assert Logger.error.call_args_list == [ + call("Repository deletion order not found", exc_info=True), + ] + assert database.list_of("assignments.RepositoryDeletionOrder") == [] + assert actions.send_email_message.call_args_list == [] + + +def test_not_found_user(database: capy.Database, format: capy.Format): + model = database.create(repository_deletion_order={"status": "TRANSFERRING"}) + Logger.info.reset_mock() + send_repository_deletion_notification.delay(1, "bob") + + assert Logger.info.call_args_list == [ + call("Executing send_repository_deletion_notification for cohort user 1"), + ] + assert Logger.error.call_args_list == [ + call("User not found for GITHUB username bob", exc_info=True), + ] + assert database.list_of("assignments.RepositoryDeletionOrder") == [ + format.to_obj_repr(model.repository_deletion_order) + ] + assert actions.send_email_message.call_args_list == [] + + +def test_transferring(database: capy.Database, format: capy.Format, utc_now): + model = database.create( + repository_deletion_order={"status": "TRANSFERRING"}, user=1, credentials_github={"username": "bob"} + ) + Logger.info.reset_mock() + send_repository_deletion_notification.delay(1, "bob") + + assert Logger.info.call_args_list == [ + call("Executing send_repository_deletion_notification for cohort user 1"), + ] + assert Logger.error.call_args_list == [] + assert database.list_of("assignments.RepositoryDeletionOrder") == [ + { + **format.to_obj_repr(model.repository_deletion_order), + "notified_at": utc_now, + } + ] + + assert actions.send_email_message.call_args_list == [ + call( + "message", + model.user.email, + { + "SUBJECT": f"We are transfering the repository {model.repository_deletion_order.repository_name} to you", + "MESSAGE": f"We are transfering the repository {model.repository_deletion_order.repository_name} to you, you have two months to accept the transfer before we delete it", + "BUTTON": "Go to the repository", + "LINK": f"https://github.com/{model.repository_deletion_order.repository_user}/{model.repository_deletion_order.repository_name}", + }, + ) + ] diff --git a/breathecode/assignments/tests/tasks/tests_set_cohort_user_assignments.py b/breathecode/assignments/tests/tasks/tests_set_cohort_user_assignments.py index 1e492649d..91083b5e4 100644 --- a/breathecode/assignments/tests/tasks/tests_set_cohort_user_assignments.py +++ b/breathecode/assignments/tests/tasks/tests_set_cohort_user_assignments.py @@ -10,8 +10,6 @@ from linked_services.django.actions import reset_app_cache from linked_services.django.service import Service -from breathecode.assignments import signals - from ...tasks import set_cohort_user_assignments from ..mixins import AssignmentsTestCase @@ -47,7 +45,7 @@ def test__without_tasks(self): self.assertEqual(self.bc.database.list_of("assignments.Task"), []) self.assertEqual(self.bc.database.list_of("admissions.CohortUser"), []) self.assertEqual(Logger.info.call_args_list, [call("Executing set_cohort_user_assignments")]) - self.assertEqual(Logger.error.call_args_list, [call("Task not found")]) + self.assertEqual(Logger.error.call_args_list, [call("Task not found", exc_info=True)]) """ 🔽🔽🔽 One Task @@ -63,7 +61,7 @@ def test__with_one_task(self): self.assertEqual(self.bc.database.list_of("assignments.Task"), [self.bc.format.to_dict(model.task)]) self.assertEqual(self.bc.database.list_of("admissions.CohortUser"), []) self.assertEqual(Logger.info.call_args_list, [call("Executing set_cohort_user_assignments")]) - self.assertEqual(Logger.error.call_args_list, [call("CohortUser not found")]) + self.assertEqual(Logger.error.call_args_list, [call("CohortUser not found", exc_info=True)]) """ 🔽🔽🔽 One Task @@ -328,7 +326,7 @@ def test__rigobot_not_found(self): call("History log saved"), ], ) - self.assertEqual(Logger.error.call_args_list, [call("App rigobot not found")]) + self.assertEqual(Logger.error.call_args_list, [call("App rigobot not found", exc_info=True)]) @patch.multiple("linked_services.django.service.Service", post=MagicMock(), put=MagicMock()) def test__rigobot_cancelled_revision(self): diff --git a/breathecode/assignments/tests/tasks/tests_student_task_notification.py b/breathecode/assignments/tests/tasks/tests_student_task_notification.py index 5ec80286a..dc645239e 100644 --- a/breathecode/assignments/tests/tasks/tests_student_task_notification.py +++ b/breathecode/assignments/tests/tasks/tests_student_task_notification.py @@ -30,8 +30,14 @@ def test_student_task_notification__without_tasks(self): self.assertEqual(self.bc.database.list_of("assignments.Task"), []) self.assertEqual(send_email_message.call_args_list, []) - self.assertEqual(Logger.info.call_args_list, [call("Starting student_task_notification")]) - self.assertEqual(Logger.error.call_args_list, [call("Task not found")]) + self.assertEqual( + Logger.info.call_args_list, + [ + call("Starting student_task_notification"), + call("Starting student_task_notification"), + ], + ) + self.assertEqual(Logger.error.call_args_list, [call("Task not found", exc_info=True)]) self.assertEqual(signals.assignment_created.delay.call_args_list, []) """ diff --git a/breathecode/assignments/tests/tasks/tests_teacher_task_notification.py b/breathecode/assignments/tests/tasks/tests_teacher_task_notification.py index fb1b2052b..1b0fdb9df 100644 --- a/breathecode/assignments/tests/tasks/tests_teacher_task_notification.py +++ b/breathecode/assignments/tests/tasks/tests_teacher_task_notification.py @@ -52,15 +52,21 @@ def test_teacher_task_notification__without_tasks(self): from breathecode.notify.actions import send_email_message - # os.environ['TEACHER_URL'] = 'https://hardcoded.url' - teacher_task_notification.delay(1) self.assertEqual(self.bc.database.list_of("assignments.Task"), []) self.assertEqual(send_email_message.call_args_list, []) - self.assertEqual(os.getenv.call_args_list, [call("TEACHER_URL")]) - self.assertEqual(Logger.info.call_args_list, [call("Starting teacher_task_notification")]) - self.assertEqual(Logger.error.call_args_list, [call("Task not found")]) + self.assertEqual(os.getenv.call_args_list, [call("TEACHER_URL"), call("TEACHER_URL")]) + self.assertEqual( + Logger.info.call_args_list, + [call("Starting teacher_task_notification"), call("Starting teacher_task_notification")], + ) + self.assertEqual( + Logger.error.call_args_list, + [ + call("Task not found", exc_info=True), + ], + ) self.assertEqual(signals.assignment_created.delay.call_args_list, []) """ @@ -113,7 +119,9 @@ def test_teacher_task_notification__with_task__with_cohort(self): self.assertEqual(Logger.error.call_args_list, []) self.assertEqual( signals.assignment_created.delay.call_args_list, - [call(instance=model.task, sender=model.task.__class__)], + [ + call(instance=model.task, sender=model.task.__class__), + ], ) """ @@ -169,7 +177,9 @@ def test_teacher_task_notification__with_task__with_cohort__lang_es(self): self.assertEqual(Logger.error.call_args_list, []) self.assertEqual( signals.assignment_created.delay.call_args_list, - [call(instance=model.task, sender=model.task.__class__)], + [ + call(instance=model.task, sender=model.task.__class__), + ], ) """ @@ -224,5 +234,7 @@ def test_teacher_task_notification__with_task__with_cohort__ends_with_slash(self self.assertEqual(Logger.error.call_args_list, []) self.assertEqual( signals.assignment_created.delay.call_args_list, - [call(instance=model.task, sender=model.task.__class__)], + [ + call(instance=model.task, sender=model.task.__class__), + ], ) diff --git a/breathecode/marketing/admin.py b/breathecode/marketing/admin.py index 53cb77200..5d8a088c9 100644 --- a/breathecode/marketing/admin.py +++ b/breathecode/marketing/admin.py @@ -287,7 +287,6 @@ def _storage_status(self, obj): "DRAFT": "bg-error", "PENDING_TRANSLATION": "bg-error", "PENDING": "bg-warning", - "WARNING": "bg-warning", "NOT_STARTED": "bg-error", "UNLISTED": "bg-warning", } diff --git a/breathecode/registry/models.py b/breathecode/registry/models.py index 1e701c91f..8db8e6696 100644 --- a/breathecode/registry/models.py +++ b/breathecode/registry/models.py @@ -923,9 +923,9 @@ def compare_versions(version1, version2): return 0 pattern = r"^v\d+\.\d+$" - if not bool(re.match(branch_name, string)): + if not bool(re.match(pattern, branch_name)): raise ValueError("Version name must follow the format vX.X, for example: v1.0") - + original_version = branch_name.replace("v", "") original_major_version = int(original_version.split(".")[0]) diff --git a/breathecode/registry/tasks.py b/breathecode/registry/tasks.py index e29ea0b88..3e1cadfc8 100644 --- a/breathecode/registry/tasks.py +++ b/breathecode/registry/tasks.py @@ -87,9 +87,9 @@ def async_pull_project_dependencies(asset_slug): # To avoid legacy issues we have to mark assets.template_url as "self" when no template is needed if asset.template_url == "self": target_asset = asset - else: + else: target_asset = Asset.get_by_github_url(asset.template_url) - + if target_asset is None: raise Exception( f"Asset {asset_slug} template {asset.template_url} not found in the database as another asset" @@ -670,9 +670,7 @@ def async_build_asset_context(asset_id): context += "This project should be delivered by sending a github repository URL. " if asset.asset_type == "PROJECT" and asset.delivery_instructions and asset.delivery_formats: - context += ( - f"This project should be delivered by adding at least one file of one of these types: {asset.delivery_formats}. " - ) + context += f"This project should be delivered by adding at least one file of one of these types: {asset.delivery_formats}. " if asset.asset_type == "PROJECT" and asset.delivery_regex_url: context += f"This project should be delivered with a URL that follows this format: {asset.delivery_regex_url}. "