From 91cabba717da17b96d28dbb9c13dabc304ed62ab Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Fri, 27 Sep 2024 14:32:08 +0000 Subject: [PATCH 1/8] now brevo is supported in the formentry --- .env.example | 2 + breathecode/marketing/actions.py | 162 +++++++++++------ breathecode/marketing/admin.py | 2 +- .../0089_activecampaignacademy_crm_vendor.py | 23 +++ breathecode/marketing/models.py | 14 ++ breathecode/marketing/views.py | 5 +- breathecode/services/brevo.py | 163 ++++++++++++++++++ 7 files changed, 316 insertions(+), 55 deletions(-) create mode 100644 breathecode/marketing/migrations/0089_activecampaignacademy_crm_vendor.py create mode 100644 breathecode/services/brevo.py diff --git a/.env.example b/.env.example index b99db3f7b..fd21e08ff 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,8 @@ FACEBOOK_REDIRECT_URL= ACTIVE_CAMPAIGN_KEY= ACTIVE_CAMPAIGN_URL= +BREVO_KEY= + GOOGLE_APPLICATION_CREDENTIALS= GOOGLE_SERVICE_KEY= GOOGLE_CLOUD_KEY= diff --git a/breathecode/marketing/actions.py b/breathecode/marketing/actions.py index bd0c4bcb6..aff5b1849 100644 --- a/breathecode/marketing/actions.py +++ b/breathecode/marketing/actions.py @@ -6,6 +6,7 @@ import numpy as np import requests +from capyc.rest_framework.exceptions import ValidationException from django.db.models import Q from django.utils import timezone from rest_framework.exceptions import APIException @@ -14,9 +15,9 @@ from breathecode.authenticate.models import CredentialsFacebook from breathecode.notify.actions import send_email_message from breathecode.services.activecampaign import ACOldClient, ActiveCampaign, ActiveCampaignClient, acp_ids, map_ids +from breathecode.services.brevo import Brevo from breathecode.utils import getLogger from breathecode.utils.i18n import translation -from capyc.rest_framework.exceptions import ValidationException from .models import AcademyAlias, ActiveCampaignAcademy, Automation, FormEntry, Tag @@ -152,12 +153,16 @@ def validate_email(email, lang): return email_status -def set_optional(contact, key, data, custom_key=None): +def set_optional(contact, key, data, custom_key=None, crm_vendor="ACTIVE_CAMPAIGN"): if custom_key is None: custom_key = key - if custom_key in data: - contact["field[" + acp_ids[key] + ",0]"] = data[custom_key] + if crm_vendor == "ACTIVE_CAMPAIGN": + if custom_key in data: + contact["field[" + acp_ids[key] + ",0]"] = data[custom_key] + else: + if custom_key in data: + contact[key] = data[custom_key] return contact @@ -177,7 +182,7 @@ def get_lead_tags(ac_academy, form_entry): tags = list(chain(strong_tags, soft_tags, dicovery_tags, other_tags)) if len(tags) != len(_tags): - message = "Some tag applied to the contact not found or have tag_type different than [STRONG, SOFT, DISCOVER, OTHER]: " + message = f"Some tag applied to the contact not found or have tag_type different than [STRONG, SOFT, DISCOVER, OTHER] for this academy {ac_academy.academy.name}. " message += f'Check for the follow tags: {",".join(_tags)}' raise Exception(message) @@ -198,7 +203,7 @@ def get_lead_automations(ac_academy, form_entry): raise Exception(f"The specified automation {_name} was not found for this AC Academy") logger.debug(f"found {str(count)} automations") - return automations.values_list("acp_id", flat=True) + return automations def add_to_active_campaign(contact, academy_id: int, automation_id: int): @@ -250,7 +255,7 @@ def add_to_active_campaign(contact, academy_id: int, automation_id: int): logger.error(f"error triggering automation with id {str(acp_id)}", response) raise APIException("Could not add contact to Automation") - logger.info(f"Triggered automation with id {str(acp_id)}", response) + logger.debug(f"Triggered automation with id {str(acp_id)}", response) def register_new_lead(form_entry=None): @@ -275,7 +280,9 @@ def register_new_lead(form_entry=None): ac_academy = ActiveCampaignAcademy.objects.filter(academy__slug=form_entry["location"]).first() if ac_academy is None: - raise RetryTask(f"No academy found with slug {form_entry['location']}") + raise RetryTask( + f"No CRM vendor information for academy with slug {form_entry['location']}. Is Active Campaign or Brevo used?" + ) automations = get_lead_automations(ac_academy, form_entry) @@ -285,13 +292,24 @@ def register_new_lead(form_entry=None): else: logger.info("automations not found") - tags = get_lead_tags(ac_academy, form_entry) - logger.info("found tags") - logger.info(set(t.slug for t in tags)) + # Tags are only for ACTIVE CAMPAIGN + tags = [] + if ac_academy.crm_vendor == "BREVO": + # brevo uses slugs instead of ID for automations + automations = automations.values_list("slug", flat=True) + if "tags" in form_entry and len(form_entry["tags"]) > 0: + raise Exception("Brevo CRM does not support tags, please remove them from the contact payload") + else: + automations = automations.values_list("acp_id", flat=True) + tags = get_lead_tags(ac_academy, form_entry) + logger.info("found tags") + logger.info(set(t.slug for t in tags)) if (automations is None or len(automations) == 0) and len(tags) > 0: if tags[0].automation is None: - raise ValidationException("No automation was specified and the the specified tag has no automation either") + raise ValidationException( + "No automation was specified and the specified tag (if any) has no automation either" + ) automations = [tags[0].automation.acp_id] @@ -326,30 +344,33 @@ def register_new_lead(form_entry=None): "phone": form_entry["phone"], } - contact = set_optional(contact, "utm_url", form_entry) - contact = set_optional(contact, "utm_location", form_entry, "location") - contact = set_optional(contact, "course", form_entry) - contact = set_optional(contact, "utm_language", form_entry, "language") - contact = set_optional(contact, "utm_country", form_entry, "country") - contact = set_optional(contact, "utm_campaign", form_entry) - contact = set_optional(contact, "utm_source", form_entry) - contact = set_optional(contact, "utm_content", form_entry) - contact = set_optional(contact, "utm_medium", form_entry) - contact = set_optional(contact, "utm_plan", form_entry) - contact = set_optional(contact, "utm_placement", form_entry) - contact = set_optional(contact, "utm_term", form_entry) - contact = set_optional(contact, "gender", form_entry, "sex") - contact = set_optional(contact, "client_comments", form_entry) - contact = set_optional(contact, "gclid", form_entry) - contact = set_optional(contact, "current_download", form_entry) - contact = set_optional(contact, "referral_key", form_entry) + contact = set_optional(contact, "utm_url", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_location", form_entry, "location", crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "course", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_language", form_entry, "language", crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_country", form_entry, "country", crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_campaign", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_source", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_content", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_medium", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_plan", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_placement", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "utm_term", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "gender", form_entry, "sex", crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "client_comments", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "gclid", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "current_download", form_entry, crm_vendor=ac_academy.crm_vendor) + contact = set_optional(contact, "referral_key", form_entry, crm_vendor=ac_academy.crm_vendor) + + # only for brevo + if ac_academy.crm_vendor == "BREVO": + contact = set_optional(contact, "utm_landing", form_entry, crm_vendor=ac_academy.crm_vendor) entry = FormEntry.objects.filter(id=form_entry["id"]).first() - if not entry: raise ValidationException("FormEntry not found (id: " + str(form_entry["id"]) + ")") - if "contact-us" == tags[0].slug: + if len(tags) > 0 and "contact-us" == tags[0].slug: obj = {} if ac_academy.academy: @@ -370,37 +391,54 @@ def register_new_lead(form_entry=None): ) is_duplicate = entry.is_duplicate(form_entry) - # ENV Variable to fake lead storage + if is_duplicate: + entry.storage_status = "DUPLICATED" + entry.save() + logger.info("FormEntry is considered a duplicate, not sent to CRM and no automations or tags added") + return entry + # ENV Variable to fake lead storage if get_save_leads() == "FALSE": - entry.storage_status_text = "Saved but not send to AC because SAVE_LEADS is FALSE" + entry.storage_status_text = "Saved but not send to CRM because SAVE_LEADS is FALSE" entry.storage_status = "PERSISTED" if not is_duplicate else "DUPLICATED" entry.save() return entry - logger.info("ready to send contact with following details: " + str(contact)) + if ac_academy.crm_vendor == "ACTIVE_CAMPAIGN": + entry = send_to_active_campaign(entry, ac_academy, contact, automations, tags) + elif ac_academy.crm_vendor == "BREVO": + entry = send_to_brevo(entry, ac_academy, contact, automations) + + if entry.storage_status in ["ERROR"]: + return entry + + entry.storage_status = "PERSISTED" + entry.save() + + form_entry["storage_status"] = "PERSISTED" + + return entry + + +def send_to_active_campaign(form_entry, ac_academy, contact, automations, tags): + old_client = ACOldClient(ac_academy.ac_url, ac_academy.ac_key) response = old_client.contacts.create_contact(contact) contact_id = response["subscriber_id"] # save contact_id from active campaign - entry.ac_contact_id = contact_id - entry.save() + form_entry.ac_contact_id = contact_id + form_entry.save() if "subscriber_id" not in response: logger.error("error adding contact", response) - entry.storage_status = "ERROR" - entry.storage_status_text = "Could not save contact in CRM: Subscriber_id not found" - entry.save() - - if is_duplicate: - entry.storage_status = "DUPLICATED" - entry.save() - logger.info("FormEntry is considered a duplicate, no automations or tags added") - return entry + form_entry.storage_status = "ERROR" + form_entry.storage_status_text = "Could not save contact in CRM: Subscriber_id not found" + form_entry.save() + return form_entry client = ActiveCampaignClient(ac_academy.ac_url, ac_academy.ac_key) - if automations and not is_duplicate: + if automations: for automation_id in automations: data = {"contactAutomation": {"contact": contact_id, "automation": automation_id}} response = client.contacts.add_a_contact_to_an_automation(data) @@ -408,22 +446,36 @@ def register_new_lead(form_entry=None): if "contacts" not in response: logger.error(f"error triggering automation with id {str(automation_id)}", response) raise APIException("Could not add contact to Automation") - logger.info(f"Triggered automation with id {str(automation_id)} " + str(response)) + logger.debug(f"Triggered automation with id {str(automation_id)} " + str(response)) logger.info("automations was executed successfully") - if tags and not is_duplicate: + if tags: for t in tags: data = {"contactTag": {"contact": contact_id, "tag": t.acp_id}} response = client.contacts.add_a_tag_to_contact(data) logger.info("contact was tagged successfully") - entry.storage_status = "PERSISTED" - entry.save() + return form_entry - form_entry["storage_status"] = "PERSISTED" - return entry +def send_to_brevo(form_entry, ac_academy, contact, automations): + + if automations.count() > 1: + raise Exception("Only one automation at a time is allowed for Brevo") + + _a = automations.first() + + brevo_client = Brevo(ac_academy.ac_key) + response = brevo_client.create_contact(contact, _a) + + # Brevo does not answer with the contact ID when the create_contact + # is being made thru triggering a brevo event + if response and "id" in response: + form_entry.ac_contact_id = response["id"] + form_entry.save() + + return form_entry def test_ac_connection(ac_academy): @@ -487,6 +539,9 @@ def update_deal_custom_fields(formentry_id: int): def sync_tags(ac_academy): + if ac_academy.crm_vendor == "BREVO": + raise Exception("Sync method has not been implemented for Brevo Tags") + client = ActiveCampaignClient(ac_academy.ac_url, ac_academy.ac_key) response = client.tags.list_all_tags(limit=100) @@ -518,6 +573,9 @@ def sync_tags(ac_academy): def sync_automations(ac_academy): + if ac_academy.crm_vendor == "BREVO": + raise Exception("Sync method has not been implemented for Brevo Automations") + client = ActiveCampaignClient(ac_academy.ac_url, ac_academy.ac_key) response = client.automations.list_all_automations(limit=100) diff --git a/breathecode/marketing/admin.py b/breathecode/marketing/admin.py index d007bd760..53cb77200 100644 --- a/breathecode/marketing/admin.py +++ b/breathecode/marketing/admin.py @@ -104,7 +104,7 @@ def __init__(self, *args, **kwargs): class ACAcademyAdmin(admin.ModelAdmin, AdminExportCsvMixin): form = CustomForm search_fields = ["academy__name", "academy__slug"] - list_display = ("id", "academy", "ac_url", "sync_status", "last_interaction_at", "sync_message") + list_display = ("id", "academy", "crm_vendor", "ac_url", "sync_status", "last_interaction_at", "sync_message") list_filter = ["academy__slug", "sync_status"] actions = [test_ac, sync_ac_tags, sync_ac_automations] diff --git a/breathecode/marketing/migrations/0089_activecampaignacademy_crm_vendor.py b/breathecode/marketing/migrations/0089_activecampaignacademy_crm_vendor.py new file mode 100644 index 000000000..9e6bce32c --- /dev/null +++ b/breathecode/marketing/migrations/0089_activecampaignacademy_crm_vendor.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.1 on 2024-09-25 18:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("marketing", "0088_alter_formentry_storage_status"), + ] + + operations = [ + migrations.AddField( + model_name="activecampaignacademy", + name="crm_vendor", + field=models.CharField( + choices=[("ACTIVE_CAMPAIGN", "Active Campaign"), ("BREVO", "Brevo")], + default="ACTIVE_CAMPAIGN", + help_text="Only one vendor allowed per academy, defaults to active campaign", + max_length=20, + ), + ), + ] diff --git a/breathecode/marketing/models.py b/breathecode/marketing/models.py index 99c2ad114..66bfa3ae7 100644 --- a/breathecode/marketing/models.py +++ b/breathecode/marketing/models.py @@ -38,6 +38,13 @@ class Meta: (COMPLETED, "Completed"), ) +ACTIVE_CAMPAIGN = "ACTIVE_CAMPAIGN" +BREVO = "BREVO" +CRM_VENDORS = ( + (ACTIVE_CAMPAIGN, "Active Campaign"), + (BREVO, "Brevo"), +) + class ActiveCampaignAcademy(models.Model): ac_key = models.CharField(max_length=150) @@ -48,6 +55,13 @@ class ActiveCampaignAcademy(models.Model): academy = models.OneToOneField(Academy, on_delete=models.CASCADE) + crm_vendor = models.CharField( + max_length=20, + choices=CRM_VENDORS, + default=ACTIVE_CAMPAIGN, + help_text="Only one vendor allowed per academy, defaults to active campaign", + ) + duplicate_leads_delta_avoidance = models.DurationField( default=timedelta(minutes=30), help_text="Leads that apply to the same course on this timedelta will not be sent to AC", diff --git a/breathecode/marketing/views.py b/breathecode/marketing/views.py index dbbbff317..4f153596f 100644 --- a/breathecode/marketing/views.py +++ b/breathecode/marketing/views.py @@ -7,9 +7,10 @@ import re from datetime import timedelta from urllib import parse -from slugify import slugify + import pandas as pd import pytz +from capyc.rest_framework.exceptions import ValidationException from circuitbreaker import CircuitBreakerError from django.contrib.auth.models import AnonymousUser from django.db.models import CharField, Count, F, Func, Q, Value @@ -23,6 +24,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_csv.renderers import CSVRenderer +from slugify import slugify import breathecode.marketing.tasks as tasks from breathecode.admissions.models import Academy @@ -36,7 +38,6 @@ from breathecode.utils.decorators import validate_captcha, validate_captcha_challenge from breathecode.utils.find_by_full_name import query_like_by_full_name from breathecode.utils.i18n import translation -from capyc.rest_framework.exceptions import ValidationException from .actions import convert_data_frame, sync_automations, sync_tags, validate_email from .models import ( diff --git a/breathecode/services/brevo.py b/breathecode/services/brevo.py new file mode 100644 index 000000000..4d4bf1b41 --- /dev/null +++ b/breathecode/services/brevo.py @@ -0,0 +1,163 @@ +import logging + +import requests +from requests.exceptions import JSONDecodeError + +logger = logging.getLogger(__name__) + +AC_MAPS = { + "email": "EMAIL", + "first_name": "FIRSTNAME", + "last_name": "LASTNAME", + "phone": "PHONE", # or "WHATSAPP", "SMS" depending on your needs + "utm_location": "UTM_LOCATION", + "utm_country": "COUNTRY", + "utm_campaign": "UTM_CAMPAIGN", + "utm_content": "UTM_CONTENT", + "utm_medium": "UTM_MEDIUM", + "utm_placement": "UTM_PLACEMENT", + "utm_term": "UTM_TERM", + "utm_source": "UTM_SOURCE", + "utm_plan": "PLAN", + "gender": "GENDER", + "course": "COURSE", + "gclid": "GCLID", + "utm_url": "CONVERSION_URL", + "utm_language": "LANGUAGE", + "utm_landing": "LANDING_URL", + "referral_key": "REFERRAL_KEY", + "client_comments": None, # It will be ignored because its none + "current_download": None, # It will be ignored because its none +} + + +def map_contact_keys(_contact): + # Check if all keys in contact exist in AC_MAPS + missing = [] + for key in _contact: + if key not in AC_MAPS: + missing.append(key) + + if len(missing) > 0: + _keys = ",".join(missing) + raise KeyError(f"The following keys are missing on AC_MAPS dictionary: '{_keys}'") + + # Replace keys in the contact dictionary based on AC_MAPS + mapped_contact = {AC_MAPS[key]: value for key, value in _contact.items() if AC_MAPS[key] is not None} + + return mapped_contact + + +class BrevoAuthException(Exception): + pass + + +class Brevo: + HOST = "https://api.brevo.com/v3" + headers = {} + + def __init__(self, token=None, org=None, host=None): + self.token = token + self.org = org + self.page_size = 100 + if host is not None: + self.HOST = host + + def get(self, action_name, request_data=None): + + if request_data is None: + request_data = {} + + return self._call("GET", action_name, params=request_data) + + def head(self, action_name, request_data=None): + + if request_data is None: + request_data = {} + + return self._call("HEAD", action_name, params=request_data) + + def post(self, action_name, request_data=None): + + if request_data is None: + request_data = {} + + return self._call("POST", action_name, json=request_data) + + def delete(self, action_name, request_data=None): + + if request_data is None: + request_data = {} + + return self._call("DELETE", action_name, params=request_data) + + def _call(self, method_name, action_name, params=None, json=None): + + self.headers = { + "api-key": self.token, + "Content-type": "application/json", + } + + url = self.HOST + action_name + resp = requests.request(method=method_name, url=url, headers=self.headers, params=params, json=json, timeout=2) + + if resp.status_code >= 200 and resp.status_code < 300: + if method_name in ["DELETE", "HEAD"]: + return resp + + try: + data = resp.json() + return data + except JSONDecodeError: + payload = resp.text + return payload + else: + logger.debug(f"Error call {method_name}: /{action_name}") + if resp.status_code == 401: + raise BrevoAuthException("Invalid credentials when calling the Brevo API") + + error_message = str(resp.status_code) + try: + error = resp.json() + error_message = error["message"] + logger.debug(error) + except Exception: + pass + + raise Exception( + f"Unable to communicate with Brevo API for {method_name} {action_name}, error: {error_message}" + ) + + # def create_contact(self, email: str, contact: dict, lists: list): + + # try: + # body = { + # "attributes": { + # **map_contact_keys(contact) + # }, + # "updateEnabled": True, + # "email": email, + # "ext_id": attribution_id, + # "listIds": lists, + # } + # response = self.post("/contacts", request_data=body) + # return response.status_code == 201 + # except Exception: + # return False + + def create_contact(self, contact: dict, automation_slug): + try: + body = { + "event_name": "add_to_automation", + "identifiers": {"email_id": contact["email"]}, + "contact_properties": {**map_contact_keys(contact)}, + "event_properties": { + "automation_slug": automation_slug, + }, + } + data = self.post("/events", request_data=body) + return data + except Exception as e: + logger.exception("Error while creating contact in Brevo") + raise e + # return False From 1b7fd6dce15b8c2246860fa3c2b3ea9f2cc4e6b6 Mon Sep 17 00:00:00 2001 From: Alejandro Sanchez Date: Sat, 28 Sep 2024 00:44:39 +0000 Subject: [PATCH 2/8] fixed issue with value_list on formentry --- breathecode/marketing/actions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/breathecode/marketing/actions.py b/breathecode/marketing/actions.py index aff5b1849..87cd8e78f 100644 --- a/breathecode/marketing/actions.py +++ b/breathecode/marketing/actions.py @@ -300,7 +300,9 @@ def register_new_lead(form_entry=None): if "tags" in form_entry and len(form_entry["tags"]) > 0: raise Exception("Brevo CRM does not support tags, please remove them from the contact payload") else: - automations = automations.values_list("acp_id", flat=True) + if hasattr(automations, "values_list"): + automations = automations.values_list("acp_id", flat=True) + tags = get_lead_tags(ac_academy, form_entry) logger.info("found tags") logger.info(set(t.slug for t in tags)) From 04a4df442794fc56faf8f910812fe73edc87cd9e Mon Sep 17 00:00:00 2001 From: Tomas Gonzalez <56565994+tommygonzaleza@users.noreply.github.com> Date: Mon, 30 Sep 2024 13:04:38 -0400 Subject: [PATCH 3/8] Update serializers.py --- breathecode/admissions/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/breathecode/admissions/serializers.py b/breathecode/admissions/serializers.py index 9d82b16cf..575d7fc0e 100644 --- a/breathecode/admissions/serializers.py +++ b/breathecode/admissions/serializers.py @@ -999,7 +999,7 @@ def validate(self, data: OrderedDict): mandatory_slugs.append(assignment["slug"]) has_tasks = ( - Task.objects.filter(associated_slug__in=mandatory_slugs) + Task.objects.filter(associated_slug__in=mandatory_slugs, user_id=user.id, cohort__id=cohort.id) .exclude(revision_status__in=["APPROVED", "IGNORED"]) .count() ) From 0e2498ccc4f14ea6d7d05d9ab9db2395e701d625 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Tue, 1 Oct 2024 12:16:11 -0500 Subject: [PATCH 4/8] fix coupon error when it has 100% discount --- breathecode/payments/views.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/breathecode/payments/views.py b/breathecode/payments/views.py index c7dfe75fe..e9fa60b31 100644 --- a/breathecode/payments/views.py +++ b/breathecode/payments/views.py @@ -1,5 +1,7 @@ from datetime import timedelta +from capyc.core.shorteners import C +from capyc.rest_framework.exceptions import PaymentException, ValidationException from django.core.cache import cache from django.db import transaction from django.db.models import CharField, Q, Value @@ -78,8 +80,6 @@ from breathecode.utils.decorators.capable_of import capable_of from breathecode.utils.i18n import translation from breathecode.utils.redis import Lock -from capyc.core.shorteners import C -from capyc.rest_framework.exceptions import PaymentException, ValidationException logger = getLogger(__name__) @@ -1944,8 +1944,9 @@ def post(self, request): try: plan = bag.plans.filter().first() option = plan.financing_options.filter(how_many_months=bag.how_many_installments).first() + original_price = option.monthly_price coupons = bag.coupons.all() - amount = get_discounted_price(option.monthly_price, coupons) + amount = get_discounted_price(original_price, coupons) bag.monthly_price = option.monthly_price except Exception: @@ -1962,12 +1963,17 @@ def post(self, request): elif not available_for_free_trial and not available_free: amount = get_amount_by_chosen_period(bag, chosen_period, lang) coupons = bag.coupons.all() + original_price = amount amount = get_discounted_price(amount, coupons) else: + original_price = 0 amount = 0 - if amount == 0 and Subscription.objects.filter(user=request.user, plans__in=bag.plans.all()).count(): + if ( + original_price == 0 + and Subscription.objects.filter(user=request.user, plans__in=bag.plans.all()).count() + ): raise ValidationException( translation( lang, @@ -1981,7 +1987,7 @@ def post(self, request): # actions.check_dependencies_in_bag(bag, lang) if ( - amount == 0 + original_price == 0 and not available_free and available_for_free_trial and not bag.plans.filter(plan_offer_from__id__gte=1).exists() @@ -2028,7 +2034,7 @@ def post(self, request): transaction.savepoint_commit(sid) - if amount == 0: + if original_price == 0: tasks.build_free_subscription.delay(bag.id, invoice.id, conversion_info=conversion_info) elif bag.how_many_installments > 0: From 7317706ea4a04dc9842b3e47cbfddb23a0f03aad Mon Sep 17 00:00:00 2001 From: gustavomm19 Date: Tue, 1 Oct 2024 17:17:49 +0000 Subject: [PATCH 5/8] adding endpoint to retreive academy provisioning profile --- breathecode/provisioning/serializers.py | 7 +++++++ breathecode/provisioning/urls.py | 6 ++++++ breathecode/provisioning/views.py | 18 +++++++++++++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/breathecode/provisioning/serializers.py b/breathecode/provisioning/serializers.py index 656408ae1..b209359a6 100644 --- a/breathecode/provisioning/serializers.py +++ b/breathecode/provisioning/serializers.py @@ -15,6 +15,7 @@ class AcademySerializer(serpy.Serializer): # Use a Field subclass like IntField if you need more validation. id = serpy.Field() name = serpy.Field() + slug = serpy.Field() class ContainerMeSmallSerializer(serpy.Serializer): @@ -77,6 +78,12 @@ class GetProvisioningBillSerializer(serpy.Serializer): title = serpy.Field() +class GetProvisioningProfile(serpy.Serializer): + id = serpy.Field() + vendor = GetProvisioningVendorSerializer(required=False) + academy = AcademySerializer(required=False) + + class GetProvisioningConsumptionKindSerializer(serpy.Serializer): id = serpy.Field() product_name = serpy.Field() diff --git a/breathecode/provisioning/urls.py b/breathecode/provisioning/urls.py index ffa4ea11b..14e90bfc9 100644 --- a/breathecode/provisioning/urls.py +++ b/breathecode/provisioning/urls.py @@ -2,6 +2,7 @@ from .views import ( AcademyProvisioningUserConsumptionView, AcademyBillView, + ProvisioningProfileView, UploadView, redirect_new_container, redirect_new_container_public, @@ -19,6 +20,11 @@ path("academy/userconsumption", AcademyProvisioningUserConsumptionView.as_view(), name="academy_userconsumption"), path("academy/bill", AcademyBillView.as_view(), name="academy_bill_id"), path("academy/bill/", AcademyBillView.as_view(), name="academy_bill_id"), + path( + "academy//provisioningprofile", + ProvisioningProfileView.as_view(), + name="academy_id_provisioning_profile", + ), path("bill/html", render_html_all_bills, name="bill_html"), path("bill//html", render_html_bill, name="bill_id_html"), # path('academy/me/container', ContainerMeView.as_view()), diff --git a/breathecode/provisioning/views.py b/breathecode/provisioning/views.py index d7cf9a924..6a4f32649 100644 --- a/breathecode/provisioning/views.py +++ b/breathecode/provisioning/views.py @@ -29,6 +29,7 @@ ProvisioningBillHTMLSerializer, ProvisioningBillSerializer, ProvisioningUserConsumptionHTMLResumeSerializer, + GetProvisioningProfile, ) from breathecode.utils import capable_of, cut_csv from breathecode.utils.api_view_extensions.api_view_extensions import APIViewExtensions @@ -38,7 +39,7 @@ from breathecode.utils.views import private_view, render_message from .actions import get_provisioning_vendor -from .models import BILL_STATUS, ProvisioningBill, ProvisioningUserConsumption +from .models import BILL_STATUS, ProvisioningBill, ProvisioningUserConsumption, ProvisioningProfile @private_view() @@ -728,6 +729,21 @@ def put(self, request, bill_id=None, academy_id=None): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +class ProvisioningProfileView(APIView): + + extensions = APIViewExtensions(paginate=True) + + def get(self, request, academy_id=None): + handler = self.extensions(request) + + items = ProvisioningProfile.objects.filter(academy__id=academy_id) + + items = handler.queryset(items) + serializer = GetProvisioningProfile(items, many=True) + + return handler.response(serializer.data) + + # class ContainerMeView(APIView): # """ # List all snippets, or create a new snippet. From 9adf18e878ce7eb1643c728ee668ebc05d04a084 Mon Sep 17 00:00:00 2001 From: gustavomm19 Date: Wed, 2 Oct 2024 08:12:31 +0000 Subject: [PATCH 6/8] add tests --- breathecode/provisioning/serializers.py | 1 + .../tests_academy_id_provisioning_profile.py | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 breathecode/provisioning/tests/urls/tests_academy_id_provisioning_profile.py diff --git a/breathecode/provisioning/serializers.py b/breathecode/provisioning/serializers.py index b209359a6..7acb79afc 100644 --- a/breathecode/provisioning/serializers.py +++ b/breathecode/provisioning/serializers.py @@ -50,6 +50,7 @@ class ContainerMeBigSerializer(serpy.Serializer): class GetProvisioningVendorSerializer(serpy.Serializer): id = serpy.Field() name = serpy.Field() + workspaces_url = serpy.Field() class GetProvisioningBillSmallSerializer(serpy.Serializer): diff --git a/breathecode/provisioning/tests/urls/tests_academy_id_provisioning_profile.py b/breathecode/provisioning/tests/urls/tests_academy_id_provisioning_profile.py new file mode 100644 index 000000000..cc3105868 --- /dev/null +++ b/breathecode/provisioning/tests/urls/tests_academy_id_provisioning_profile.py @@ -0,0 +1,75 @@ +""" +Test /v1/provisioning/academy/provisioningprofile +""" + +import json +import os +from django.urls.base import reverse_lazy +from rest_framework import status + +from ..mixins import ProvisioningTestCase + + +def get_serializer(provisioning_profile, data={}): + return { + "id": provisioning_profile.id, + "vendor": provisioning_profile.vendor, + "academy": { + "id": provisioning_profile.academy.id, + "name": provisioning_profile.academy.name, + "slug": provisioning_profile.academy.slug, + }, + **data, + } + + +class ProvisioningTestSuite(ProvisioningTestCase): + + # When: no auth + # Then: should return 401 + def test_upload_without_auth(self): + + url = reverse_lazy("provisioning:academy_id_provisioning_profile", kwargs={"academy_id": 1}) + + response = self.client.get(url) + json = response.json() + expected = {"detail": "Authentication credentials were not provided.", "status_code": 401} + + self.assertEqual(json, expected) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_no_profiles(self): + + url = reverse_lazy("provisioning:academy_id_provisioning_profile", kwargs={"academy_id": 1}) + + model = self.bc.database.create( + user=1, + profile_academy=1, + ) + self.client.force_authenticate(model.user) + + response = self.client.get(url) + json = response.json() + expected = [] + + self.assertEqual(json, expected) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_provisioning_profiles(self): + + url = reverse_lazy("provisioning:academy_id_provisioning_profile", kwargs={"academy_id": 1}) + + model = self.bc.database.create( + user=1, + profile_academy=1, + provisioning_profile=1, + vendor=1, + ) + self.client.force_authenticate(model.user) + + response = self.client.get(url) + json = response.json() + expected = [get_serializer(model.provisioning_profile)] + + self.assertEqual(json, expected) + self.assertEqual(response.status_code, status.HTTP_200_OK) From af73d133a8346f65076c5b8859a600f54b77fa69 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Mon, 7 Oct 2024 12:30:07 -0500 Subject: [PATCH 7/8] add asset context --- breathecode/registry/admin.py | 17 + .../registry/migrations/0046_assetcontext.py | 22 + breathecode/registry/models.py | 102 ++- breathecode/registry/receivers.py | 43 +- breathecode/registry/signals.py | 1 + .../registry/tests/signals/__init__.py | 0 .../tests/signals/tests_asset_saved.py | 818 ++++++++++++++++++ .../tests/signals/tests_m2m_changed.py | 150 ++++ .../activecampaign/actions/deal_update.py | 4 +- docs/infrastructure/journal.md | 6 +- postgres.sh | 28 + 11 files changed, 1186 insertions(+), 5 deletions(-) create mode 100644 breathecode/registry/migrations/0046_assetcontext.py create mode 100644 breathecode/registry/tests/signals/__init__.py create mode 100644 breathecode/registry/tests/signals/tests_asset_saved.py create mode 100644 breathecode/registry/tests/signals/tests_m2m_changed.py create mode 100644 postgres.sh diff --git a/breathecode/registry/admin.py b/breathecode/registry/admin.py index f6a54d7ff..a5df2c8fa 100644 --- a/breathecode/registry/admin.py +++ b/breathecode/registry/admin.py @@ -24,6 +24,7 @@ AssetAlias, AssetCategory, AssetComment, + AssetContext, AssetErrorLog, AssetImage, AssetKeyword, @@ -894,3 +895,19 @@ def real_value(self, obj): "FETCH_TEXT": "Fetch from: " + obj.value, } return format_html(f"{_values[obj.var_type]}") + + +@admin.register(AssetContext) +class AssetContextAdmin(admin.ModelAdmin): + list_display = ["asset", "ai_context"] + search_fields = ("asset__slug", "asset__title") + list_filter = ["asset__category", "asset__lang"] + + def ai_context(self, obj: AssetContext): + lenght = len(obj.ai_context) + ai_context = obj.ai_context + + if lenght <= 20: + return ai_context + + return ai_context[:20] + "..." diff --git a/breathecode/registry/migrations/0046_assetcontext.py b/breathecode/registry/migrations/0046_assetcontext.py new file mode 100644 index 000000000..23c7f5ac2 --- /dev/null +++ b/breathecode/registry/migrations/0046_assetcontext.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.1 on 2024-10-07 17:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registry", "0045_asset_agent"), + ] + + operations = [ + migrations.CreateModel( + name="AssetContext", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("ai_context", models.TextField()), + ("asset", models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to="registry.asset")), + ], + ), + ] diff --git a/breathecode/registry/models.py b/breathecode/registry/models.py index b41624235..7f6b5767e 100644 --- a/breathecode/registry/models.py +++ b/breathecode/registry/models.py @@ -17,7 +17,7 @@ from breathecode.admissions.models import Academy, SyllabusVersion from breathecode.assessment.models import Assessment -from .signals import asset_readme_modified, asset_slug_modified, asset_status_updated, asset_title_modified +from .signals import asset_readme_modified, asset_saved, asset_slug_modified, asset_status_updated, asset_title_modified __all__ = ["AssetTechnology", "Asset", "AssetAlias"] logger = logging.getLogger(__name__) @@ -36,6 +36,12 @@ (3, 3), ) +LANG_MAP = { + "en": "english", + "es": "spanish", + "it": "italian", +} + class SyllabusVersionProxy(SyllabusVersion): @@ -511,6 +517,93 @@ def __init__(self, *args, **kwargs): def __str__(self): return f"{self.slug}" + def build_ai_context(self): + lang = self.lang or self.category.lang + lang_name = LANG_MAP.get(lang, lang) + + context = f"This {self.asset_type} about {self.title} is written in {lang_name}. " + + translations = ", ".join([x.title for x in self.all_translations.all()]) + if translations: + context = context[:-2] + context += f", and it has the following translations: {translations}. " + + if self.solution_url: + context = context[:-2] + context += f", and it has a solution code this link is: {self.solution_url}. " + + if self.solution_video_url: + context = context[:-2] + context += f", and it has a video solution this link is {self.solution_video_url}. " + + context += f"It's category related is (what type of skills the student will get) {self.category.title}. " + + technologies = ", ".join([x.title for x in self.technologies.filter(Q(lang=lang) | Q(lang=None))]) + if technologies: + context += f"This asset is about the following technologies: {technologies}. " + + if self.external: + context += "This asset is external, which means it opens outside 4geeks. " + + if self.interactive: + context += ( + "This asset opens on LearnPack so it has a step-by-step of the exercises that you should follow. " + ) + + if self.gitpod: + context += ( + f"This {self.asset_type} can be opened both locally or with click and code (This " + "way you don't have to install nothing and it will open automatically on gitpod or github codespaces). " + ) + + if self.interactive == True and self.with_video == True: + context += f"This {self.asset_type} has videos on each step. " + + if self.interactive == True and self.with_solutions == True: + context += f"This {self.asset_type} has a code solution on each step. " + + if self.duration: + context += f"This {self.asset_type} will last {self.duration}. " + + if self.difficulty: + context += f"Its difficulty is considered as {self.difficulty}. " + + if self.superseded_by and self.superseded_by.title != self.title: + context += f"This {self.asset_type} has a previous version which is: {self.superseded_by.title}. " + + if self.asset_type == "PROJECT" and not self.delivery_instructions: + context += "This project should be delivered by sending a github repository URL. " + + if self.asset_type == "PROJECT" and self.delivery_instructions and self.delivery_formats: + context += ( + f"This project should be delivered by adding a file of one of these types: {self.delivery_formats}. " + ) + + if self.asset_type == "PROJECT" and self.delivery_regex_url: + context += ( + f"This project should be delivered with a URL that follows this format: {self.delivery_regex_url}. " + ) + + assets_related = ", ".join([x.slug for x in self.assets_related.all()]) + if assets_related: + context += ( + f"In case you still need to learn more about the basics of this {self.asset_type}, " + "you can check these lessons, exercises, " + f"and related projects to get ready for this content: {assets_related}. " + ) + + if self.readme: + context += "The markdown file with " + + if self.asset_type == "PROJECT": + context += "the instructions" + else: + context += "the content" + + context += f" of this {self.asset_type} is the following: {self.readme}." + + return context + def save(self, *args, **kwargs): slug_modified = False @@ -553,6 +646,8 @@ def save(self, *args, **kwargs): if status_modified: asset_status_updated.send_robust(instance=self, sender=Asset) + asset_saved.delay(instance=self, sender=Asset) + def get_preview_generation_url(self): if self.category is not None: @@ -744,6 +839,11 @@ def get_by_slug(asset_slug, request=None, asset_type=None): return alias +class AssetContext(models.Model): + asset = models.OneToOneField(Asset, on_delete=models.CASCADE) + ai_context = models.TextField() + + class AssetAlias(models.Model): slug = models.SlugField(max_length=200, primary_key=True) asset = models.ForeignKey(Asset, on_delete=models.CASCADE) diff --git a/breathecode/registry/receivers.py b/breathecode/registry/receivers.py index ecc7282fb..9acd45dbe 100644 --- a/breathecode/registry/receivers.py +++ b/breathecode/registry/receivers.py @@ -1,7 +1,8 @@ import logging import os +from typing import Type -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver from breathecode.admissions.models import SyllabusVersion @@ -11,8 +12,9 @@ from breathecode.assignments.signals import assignment_created from breathecode.monitoring.models import RepositoryWebhook from breathecode.monitoring.signals import github_webhook +from breathecode.registry.signals import asset_saved -from .models import Asset, AssetAlias, AssetImage +from .models import Asset, AssetAlias, AssetContext, AssetImage from .signals import asset_readme_modified, asset_slug_modified, asset_title_modified from .tasks import ( async_add_syllabus_translations, @@ -150,3 +152,40 @@ def model_b_deleted(sender, instance, **kwargs): async_generate_quiz_config(instance.question.assessment.id) except Exception: pass + + +def update_asset_context(instance: Asset): + x = AssetContext.objects.filter(asset=instance).first() + if x is None: + x = AssetContext(asset=instance) + + x.ai_context = instance.build_ai_context() + x.save() + + +@receiver(asset_saved, sender=Asset) +def update_asset_context_on_field_change(sender: Type[Asset], instance: Asset, **kwargs): + update_asset_context(instance) + + +@receiver(m2m_changed, sender=Asset.all_translations.through) +def update_asset_context_on_translations_changed( + sender: Type[Asset.all_translations.through], instance: Asset, **kwargs +): + update_asset_context(instance) + + +@receiver(m2m_changed, sender=Asset.technologies.through) +def update_asset_context_on_technologies_changed( + sender: Type[Asset.technologies.through], instance: Asset, action: str, **kwargs +): + if action == "post_add": + update_asset_context(instance) + + +@receiver(m2m_changed, sender=Asset.assets_related.through) +def update_asset_context_on_assets_related_changed( + sender: Type[Asset.assets_related.through], instance: Asset, action: str, **kwargs +): + if action == "post_add": + update_asset_context(instance) diff --git a/breathecode/registry/signals.py b/breathecode/registry/signals.py index cbb8d3066..9070c626c 100644 --- a/breathecode/registry/signals.py +++ b/breathecode/registry/signals.py @@ -8,3 +8,4 @@ asset_readme_modified = emisor.signal("asset_readme_modified") asset_title_modified = emisor.signal("asset_title_modified") asset_status_updated = emisor.signal("asset_status_updated") +asset_saved = emisor.signal("asset_saved") diff --git a/breathecode/registry/tests/signals/__init__.py b/breathecode/registry/tests/signals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/breathecode/registry/tests/signals/tests_asset_saved.py b/breathecode/registry/tests/signals/tests_asset_saved.py new file mode 100644 index 000000000..ecff27659 --- /dev/null +++ b/breathecode/registry/tests/signals/tests_asset_saved.py @@ -0,0 +1,818 @@ +""" +This file will test branches of the ai_context instead of the whole content +""" + +import random +from unittest.mock import patch + +import capyc.pytest as capy +import pytest +from aiohttp_retry import Optional + +from breathecode.registry.models import Asset, AssetContext + +LANG_MAP = { + "en": "english", + "es": "spanish", + "it": "italian", +} + + +# Fixture to create an Asset instance +@pytest.fixture +def asset(): + return Asset.objects.create(slug="test-asset", title="Test Asset", status="PUBLISHED", lang="en") + + +# Fixture to mock the save method of AssetContext +@pytest.fixture +def mock_save(monkeypatch): + with patch("breathecode.registry.receivers.AssetContext.save") as mock_save: + monkeypatch.setattr(AssetContext, "save", mock_save) + yield mock_save + + +# Fixture to mock the build_ai_context method of Asset +@pytest.fixture +def mock_build_ai_context(monkeypatch): + with patch("breathecode.registry.models.Asset.build_ai_context", return_value="test-ai-context") as mock_method: + monkeypatch.setattr(Asset, "build_ai_context", mock_method) + yield mock_method + + +def db_item(data={}): + return { + "id": 1, + "asset_id": 1, + "ai_context": "", + **data, + } + + +@pytest.mark.parametrize("asset_type", ["PROJECT", "LESSON", "EXERCISE", "QUIZ", "VIDEO", "ARTICLE"]) +def test_default_branch(database: capy.Database, signals: capy.Signals, asset_type: str): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "lang": random.choice([*LANG_MAP.keys()]), + "asset_type": asset_type, + "external": False, + "interactive": False, + "gitpod": False, + }, + asset_category=1, + city=1, + country=1, + ) + + lang = LANG_MAP[model.asset.lang] + db = database.list_of("registry.AssetContext") + + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert f"This {model.asset.asset_type} about {model.asset.title} is written in {lang}" in ai_context + assert ( + f"It's category related is (what type of skills the student will get) {model.asset_category.title}" + in ai_context + ) + + +class TestAssetTypeBranches: + + def msg1(self): + return "This project should be delivered by sending a github repository URL. " + + def msg2(self, asset: Asset): + return f"This project should be delivered by adding a file of one of these types: {asset.delivery_formats}. " + + def msg3(self, asset: Asset): + return f"This project should be delivered with a URL that follows this format: {asset.delivery_regex_url}. " + + @pytest.mark.parametrize("asset_type", ["LESSON", "EXERCISE", "QUIZ", "VIDEO", "ARTICLE"]) + def test_is_not_project(self, database: capy.Database, signals: capy.Signals, asset_type: str): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "lang": random.choice([*LANG_MAP.keys()]), + "asset_type": asset_type, + "external": False, + "interactive": False, + "gitpod": False, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1() not in ai_context + assert self.msg2(model.asset) not in ai_context + assert self.msg3(model.asset) not in ai_context + + @pytest.mark.parametrize("asset_type", ["PROJECT"]) + @pytest.mark.parametrize("delivery_instructions", ["", None]) + def test_is_project__no_delivery_instructions( + self, database: capy.Database, signals: capy.Signals, asset_type: str, delivery_instructions: str + ): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "lang": random.choice([*LANG_MAP.keys()]), + "asset_type": asset_type, + "delivery_instructions": delivery_instructions, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1() in ai_context + assert self.msg2(model.asset) not in ai_context + assert self.msg3(model.asset) not in ai_context + + @pytest.mark.parametrize("asset_type", ["PROJECT"]) + def test_is_project__with_delivery_instructions__with_delivery_formats( + self, database: capy.Database, signals: capy.Signals, asset_type: str, fake: capy.Fake + ): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "lang": random.choice([*LANG_MAP.keys()]), + "asset_type": asset_type, + "delivery_instructions": fake.text(), + "delivery_formats": ",".join(["zip", "rar"]), + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1() not in ai_context + assert self.msg2(model.asset) in ai_context + assert self.msg3(model.asset) not in ai_context + + @pytest.mark.parametrize("asset_type", ["PROJECT"]) + def test_is_project__with_delivery_regex_url( + self, database: capy.Database, signals: capy.Signals, asset_type: str, fake: capy.Fake + ): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "lang": random.choice([*LANG_MAP.keys()]), + "asset_type": asset_type, + "delivery_instructions": None, + "delivery_regex_url": fake.url(), + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1() in ai_context + assert self.msg2(model.asset) not in ai_context + assert self.msg3(model.asset) in ai_context + + +class TestSolutionUrlBranches: + + def msg1(self, asset: Asset): + return f", and it has a solution code this link is: {asset.solution_url}. " + + def test_not_solution_url(self, database: capy.Database, signals: capy.Signals): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset=1, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) not in ai_context + + def test_solution_url(self, database: capy.Database, signals: capy.Signals, fake: capy.Fake): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "solution_url": fake.url(), + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) in ai_context + + +class TestSolutionVideoUrlBranches: + + def msg1(self, asset: Asset): + return f", and it has a video solution this link is {asset.solution_video_url}. " + + def test_not_solution_video_url(self, database: capy.Database, signals: capy.Signals): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset=1, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) not in ai_context + + def test_solution_video_url(self, database: capy.Database, signals: capy.Signals, fake: capy.Fake): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "solution_video_url": fake.url(), + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) in ai_context + + +class TestExternalBranches: + + def msg1(self, asset: Asset): + return f"This asset is external, which means it opens outside 4geeks. " + + def test_not_external(self, database: capy.Database, signals: capy.Signals): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "external": False, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) not in ai_context + + def test_external(self, database: capy.Database, signals: capy.Signals, fake: capy.Fake): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "solution_video_url": fake.url(), + "external": True, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) in ai_context + + +class TestInteractiveBranches: + + def msg1(self): + return "This asset opens on LearnPack so it has a step-by-step of the exercises that you should follow. " + + def msg2(self, asset: Asset): + return f"This {asset.asset_type} has videos on each step. " + + def msg3(self, asset: Asset): + return f"This {asset.asset_type} has a code solution on each step. " + + def test_not_interactive(self, database: capy.Database, signals: capy.Signals): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "interactive": False, + "with_solutions": False, + "with_video": False, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1() not in ai_context + assert self.msg2(model.asset) not in ai_context + assert self.msg3(model.asset) not in ai_context + + def test_interactive(self, database: capy.Database, signals: capy.Signals, fake: capy.Fake): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "solution_video_url": fake.url(), + "interactive": True, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1() in ai_context + assert self.msg2(model.asset) not in ai_context + assert self.msg3(model.asset) not in ai_context + + def test_interactive__with_video(self, database: capy.Database, signals: capy.Signals, fake: capy.Fake): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "solution_video_url": fake.url(), + "interactive": True, + "with_video": True, + "with_solutions": False, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1() in ai_context + assert self.msg2(model.asset) in ai_context + assert self.msg3(model.asset) not in ai_context + + def test_interactive__with_solutions(self, database: capy.Database, signals: capy.Signals, fake: capy.Fake): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "solution_video_url": fake.url(), + "interactive": True, + "with_solutions": True, + "with_video": False, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1() in ai_context + assert self.msg2(model.asset) not in ai_context + assert self.msg3(model.asset) in ai_context + + +class TestGitpodBranches: + + def msg1(self, asset: Asset): + return ( + f"This {asset.asset_type} can be opened both locally or with click and code (This " + "way you don't have to install nothing and it will open automatically on gitpod or github codespaces). " + ) + + def test_not_gitpod(self, database: capy.Database, signals: capy.Signals): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "gitpod": False, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) not in ai_context + + def test_gitpod(self, database: capy.Database, signals: capy.Signals): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "gitpod": True, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) in ai_context + + +class TestDurationBranches: + + def msg1(self, asset: Asset): + return f"This {asset.asset_type} will last {asset.duration}. " + + @pytest.mark.parametrize("duration", [None, 0]) + def test_not_duration(self, database: capy.Database, signals: capy.Signals, duration: Optional[int]): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "duration": duration, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) not in ai_context + + def test_duration(self, database: capy.Database, signals: capy.Signals): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "duration": random.randint(1, 10), + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) in ai_context + + +class TestDifficultyBranches: + + def msg1(self, asset: Asset): + return f"Its difficulty is considered as {asset.difficulty}. " + + @pytest.mark.parametrize("difficulty", [None, ""]) + def test_not_difficulty(self, database: capy.Database, signals: capy.Signals, difficulty: Optional[str]): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "difficulty": difficulty, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) not in ai_context + + def test_difficulty(self, database: capy.Database, signals: capy.Signals): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "difficulty": random.choice(["HARD", "INTERMEDIATE", "EASY", "BEGINNER"]), + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) in ai_context + + +class TestReadmeBranches: + + def msg1(self, asset: Asset): + return f"The markdown file with the instructions of this {asset.asset_type} is the following: {asset.readme}." + + def msg2(self, asset: Asset): + return f"The markdown file with the content of this {asset.asset_type} is the following: {asset.readme}." + + @pytest.mark.parametrize("readme", [None, ""]) + def test_not_readme(self, database: capy.Database, signals: capy.Signals, readme: Optional[str]): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "readme": readme, + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) not in ai_context + assert self.msg2(model.asset) not in ai_context + + def test_readme__project(self, database: capy.Database, signals: capy.Signals, fake: capy.Fake): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "readme": fake.text(), + "asset_type": "PROJECT", + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) in ai_context + assert self.msg2(model.asset) not in ai_context + + def test_readme__anything_else(self, database: capy.Database, signals: capy.Signals, fake: capy.Fake): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset={ + "readme": fake.text(), + "asset_type": random.choice(["LESSON", "EXERCISE", "QUIZ", "VIDEO", "ARTICLE"]), + }, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) not in ai_context + assert self.msg2(model.asset) in ai_context + + +class TestSupersededByBranches: + + def msg1(self, asset: Asset): + title = "" + if asset.superseded_by: + title = asset.superseded_by.title + + return f"This {asset.asset_type} has a previous version which is: {title}. " + + def test_no_superseded_by(self, database: capy.Database, signals: capy.Signals): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset=1, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) not in ai_context + + def test_superseded_by(self, database: capy.Database, signals: capy.Signals, fake: capy.Fake): + signals.enable("breathecode.registry.signals.asset_saved") + model = database.create( + asset=2, + asset_category=1, + city=1, + country=1, + ) + + model.asset[0].superseded_by = model.asset[1] + model.asset[0].save() + db = database.list_of("registry.AssetContext") + assert len(db) == 2 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset[0]) in ai_context diff --git a/breathecode/registry/tests/signals/tests_m2m_changed.py b/breathecode/registry/tests/signals/tests_m2m_changed.py new file mode 100644 index 000000000..459386435 --- /dev/null +++ b/breathecode/registry/tests/signals/tests_m2m_changed.py @@ -0,0 +1,150 @@ +""" +This file will test branches of the ai_context instead of the whole content +""" + +import random +from unittest.mock import patch + +import capyc.pytest as capy +import pytest +from aiohttp_retry import Optional + +from breathecode.registry.models import Asset, AssetContext + +LANG_MAP = { + "en": "english", + "es": "spanish", + "it": "italian", +} + + +# Fixture to create an Asset instance +@pytest.fixture +def asset(): + return Asset.objects.create(slug="test-asset", title="Test Asset", status="PUBLISHED", lang="en") + + +# Fixture to mock the save method of AssetContext +@pytest.fixture +def mock_save(monkeypatch): + with patch("breathecode.registry.receivers.AssetContext.save") as mock_save: + monkeypatch.setattr(AssetContext, "save", mock_save) + yield mock_save + + +# Fixture to mock the build_ai_context method of Asset +@pytest.fixture +def mock_build_ai_context(monkeypatch): + with patch("breathecode.registry.models.Asset.build_ai_context", return_value="test-ai-context") as mock_method: + monkeypatch.setattr(Asset, "build_ai_context", mock_method) + yield mock_method + + +def test_default_branch(database: capy.Database, signals: capy.Signals): + signals.enable("django.db.models.signals.m2m_changed") + model = database.create( + asset=1, + asset_category=1, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 0 + + +class TestTranslationsBranches: + + def msg1(self, asset: Asset): + translations = ", ".join([x.title for x in asset.all_translations.all()]) + return f", and it has the following translations: {translations}. " + + def test_translations(self, database: capy.Database, signals: capy.Signals): + signals.enable("django.db.models.signals.m2m_changed") + model = database.create( + asset=3, + asset_category=1, + city=1, + country=1, + ) + + model.asset[0].all_translations.set([model.asset[1], model.asset[2]]) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset[0]) in ai_context + + +class TestAssetsRelatedBranches: + + def msg1(self, asset: Asset): + assets_related = ", ".join([x.slug for x in asset.assets_related.all()]) + return ( + f"In case you still need to learn more about the basics of this {asset.asset_type}, " + "you can check these lessons, exercises, " + f"and related projects to get ready for this content: {assets_related}. " + ) + + def test_assets_related(self, database: capy.Database, signals: capy.Signals): + signals.enable("django.db.models.signals.m2m_changed") + model = database.create( + asset=3, + asset_category=1, + city=1, + country=1, + ) + + model.asset[0].assets_related.set([model.asset[1], model.asset[2]]) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset[0]) in ai_context + + +# technologies +class TestTechnologiesBranches: + + def msg1(self, asset: Asset): + technologies = ", ".join([x.title for x in asset.technologies.all()]) + return f"This asset is about the following technologies: {technologies}. " + + def test_assets_related(self, database: capy.Database, signals: capy.Signals): + signals.enable("django.db.models.signals.m2m_changed") + model = database.create( + asset=1, + asset_category=1, + asset_technology=2, + city=1, + country=1, + ) + + db = database.list_of("registry.AssetContext") + assert len(db) == 1 + asset_context = db[0] + + # integrity checks + assert asset_context["id"] == 1 + assert asset_context["asset_id"] == 1 + + ai_context = asset_context["ai_context"] + + # content checks + assert self.msg1(model.asset) in ai_context diff --git a/breathecode/services/activecampaign/actions/deal_update.py b/breathecode/services/activecampaign/actions/deal_update.py index 1f3bff869..165c5b3e5 100644 --- a/breathecode/services/activecampaign/actions/deal_update.py +++ b/breathecode/services/activecampaign/actions/deal_update.py @@ -31,7 +31,9 @@ def deal_update(ac_cls, webhook, payload: dict, acp_ids): ) if entry is None and "deal[contact_email]" in payload: entry = ( - FormEntry.objects.filter(email=payload["deal[contact_email]"], storage_status__in=["PERSISTED", "MANUALLY_PERSISTED"]) + FormEntry.objects.filter( + email=payload["deal[contact_email]"], storage_status__in=["PERSISTED", "MANUALLY_PERSISTED"] + ) .order_by("-created_at") .first() ) diff --git a/docs/infrastructure/journal.md b/docs/infrastructure/journal.md index 5fc51de12..055406bed 100644 --- a/docs/infrastructure/journal.md +++ b/docs/infrastructure/journal.md @@ -62,7 +62,7 @@ Reasons for the change: - Web worker was reaching 841 MB ram. -## -9/09/2024 +## -09/09/2024 - `[all]` `GOOGLE_SECRET` setted. - `[dev]` `GOOGLE_CLIENT_ID` setted. @@ -84,3 +84,7 @@ Why: Why: - Google doesn't support webhooks directly, it uses its Pub/Sub service. + +## 06/10/2024 + +- `[dev]` `HEROKU_POSTGRESQL_TEAL` was replaced by `HEROKU_POSTGRESQL_GOLD`. diff --git a/postgres.sh b/postgres.sh new file mode 100644 index 000000000..0d13d3f5b --- /dev/null +++ b/postgres.sh @@ -0,0 +1,28 @@ +#! /bin/bash + +# check this postgres url in environment variables and add-ons +heroku addons:create heroku-postgresql:standard-0 --follow HEROKU_POSTGRESQL_TEAL_URL --app breathecode-test +heroku pg:wait --app breathecode-test +heroku pg:info --app breathecode-test +heroku maintenance:on --app breathecode-test + +# check the current maintenance status +heroku maintenance --app breathecode-test + +# here appear the follower name +# wait until the follower is behind by 0 commits, this can take some minutes +heroku pg:info --app breathecode-test + +# upgrade the follower to the latest version +heroku pg:upgrade HEROKU_POSTGRESQL_FOLLOWER_URL --app breathecode-test + +# wait until the new postgres is upgraded +heroku pg:wait --app breathecode-test + +# promote the follower to primary +heroku pg:promote HEROKU_POSTGRESQL_FOLLOWER_URL --app breathecode-test + +heroku maintenance:off --app breathecode-test + +# destroy the old postgres when you are sure that everything is working +heroku addons:destroy HEROKU_POSTGRESQL_TEAL --app breathecode-test From 5beec69342098a251e87c4d78d5dfcd67cef5bbf Mon Sep 17 00:00:00 2001 From: jefer94 Date: Mon, 7 Oct 2024 12:33:56 -0500 Subject: [PATCH 8/8] simplify code --- .../tests/signals/tests_asset_saved.py | 34 +----------------- .../tests/signals/tests_m2m_changed.py | 35 +------------------ 2 files changed, 2 insertions(+), 67 deletions(-) diff --git a/breathecode/registry/tests/signals/tests_asset_saved.py b/breathecode/registry/tests/signals/tests_asset_saved.py index ecff27659..6219a5f2f 100644 --- a/breathecode/registry/tests/signals/tests_asset_saved.py +++ b/breathecode/registry/tests/signals/tests_asset_saved.py @@ -3,13 +3,12 @@ """ import random -from unittest.mock import patch import capyc.pytest as capy import pytest from aiohttp_retry import Optional -from breathecode.registry.models import Asset, AssetContext +from breathecode.registry.models import Asset LANG_MAP = { "en": "english", @@ -18,37 +17,6 @@ } -# Fixture to create an Asset instance -@pytest.fixture -def asset(): - return Asset.objects.create(slug="test-asset", title="Test Asset", status="PUBLISHED", lang="en") - - -# Fixture to mock the save method of AssetContext -@pytest.fixture -def mock_save(monkeypatch): - with patch("breathecode.registry.receivers.AssetContext.save") as mock_save: - monkeypatch.setattr(AssetContext, "save", mock_save) - yield mock_save - - -# Fixture to mock the build_ai_context method of Asset -@pytest.fixture -def mock_build_ai_context(monkeypatch): - with patch("breathecode.registry.models.Asset.build_ai_context", return_value="test-ai-context") as mock_method: - monkeypatch.setattr(Asset, "build_ai_context", mock_method) - yield mock_method - - -def db_item(data={}): - return { - "id": 1, - "asset_id": 1, - "ai_context": "", - **data, - } - - @pytest.mark.parametrize("asset_type", ["PROJECT", "LESSON", "EXERCISE", "QUIZ", "VIDEO", "ARTICLE"]) def test_default_branch(database: capy.Database, signals: capy.Signals, asset_type: str): signals.enable("breathecode.registry.signals.asset_saved") diff --git a/breathecode/registry/tests/signals/tests_m2m_changed.py b/breathecode/registry/tests/signals/tests_m2m_changed.py index 459386435..84a99ab9a 100644 --- a/breathecode/registry/tests/signals/tests_m2m_changed.py +++ b/breathecode/registry/tests/signals/tests_m2m_changed.py @@ -2,42 +2,9 @@ This file will test branches of the ai_context instead of the whole content """ -import random -from unittest.mock import patch - import capyc.pytest as capy -import pytest -from aiohttp_retry import Optional - -from breathecode.registry.models import Asset, AssetContext - -LANG_MAP = { - "en": "english", - "es": "spanish", - "it": "italian", -} - - -# Fixture to create an Asset instance -@pytest.fixture -def asset(): - return Asset.objects.create(slug="test-asset", title="Test Asset", status="PUBLISHED", lang="en") - - -# Fixture to mock the save method of AssetContext -@pytest.fixture -def mock_save(monkeypatch): - with patch("breathecode.registry.receivers.AssetContext.save") as mock_save: - monkeypatch.setattr(AssetContext, "save", mock_save) - yield mock_save - -# Fixture to mock the build_ai_context method of Asset -@pytest.fixture -def mock_build_ai_context(monkeypatch): - with patch("breathecode.registry.models.Asset.build_ai_context", return_value="test-ai-context") as mock_method: - monkeypatch.setattr(Asset, "build_ai_context", mock_method) - yield mock_method +from breathecode.registry.models import Asset def test_default_branch(database: capy.Database, signals: capy.Signals):