diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 84806ea96..461b4e924 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -5,6 +5,7 @@ name: Check on: push: {} + pull_request: {} env: PYTHON_VERSION: 3.12.3 diff --git a/breathecode/authenticate/actions.py b/breathecode/authenticate/actions.py index 20f7da6c0..77bcbbf54 100644 --- a/breathecode/authenticate/actions.py +++ b/breathecode/authenticate/actions.py @@ -730,6 +730,7 @@ def accept_invite(accepting_ids=None, user=None): invite.user = user invite.status = "ACCEPTED" + invite.process_status = "DONE" invite.save() diff --git a/breathecode/notify/serializers.py b/breathecode/notify/serializers.py index c4ed0dd45..675c5fa10 100644 --- a/breathecode/notify/serializers.py +++ b/breathecode/notify/serializers.py @@ -4,6 +4,7 @@ from breathecode.admissions.models import Academy from breathecode.utils import serpy from capyc.rest_framework.exceptions import ValidationException +from breathecode.authenticate.serializers import GetSmallAcademySerializer from .models import Hook @@ -42,3 +43,13 @@ def validate(self, data): data["user"] = self.context["request"].user return super().validate(data) + + +class SlackTeamSerializer(serpy.Serializer): + id = serpy.Field() + slack_id = serpy.Field() + name = serpy.Field() + academy = GetSmallAcademySerializer(required=False) + created_at = serpy.Field() + sync_status = serpy.Field() + sync_message = serpy.Field() diff --git a/breathecode/notify/tests/urls/tests_slack_team.py b/breathecode/notify/tests/urls/tests_slack_team.py new file mode 100644 index 000000000..f94c33900 --- /dev/null +++ b/breathecode/notify/tests/urls/tests_slack_team.py @@ -0,0 +1,113 @@ +""" +Test /slack/team +""" + +import json + +import pytest +from django.urls.base import reverse_lazy +from linked_services.django.actions import reset_app_cache +from rest_framework import status +from rest_framework.test import APIClient + +from breathecode.tests.mixins.breathecode_mixin.breathecode import Breathecode + + +@pytest.fixture(autouse=True) +def setup(db): + reset_app_cache() + yield + + +def get_serializer(team, data={}): + return { + "id": team.id, + "slack_id": team.slack_id, + "name": team.name, + "academy": { + "id": team.academy.id, + "name": team.academy.name, + "slug": team.academy.slug, + }, + "created_at": team.created_at, + "sync_message": team.sync_message, + "sync_status": team.sync_status, + **data, + } + + +# When: no auth +# Then: response 401 +def test_no_auth(bc: Breathecode, client: APIClient): + url = reverse_lazy("notify:slack_team") + response = client.get(url) + + json = response.json() + expected = {"detail": "Authentication credentials were not provided.", "status_code": 401} + + assert json == expected + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert bc.database.list_of("notify.SlackTeam") == [] + + +def test_no_teams(bc: Breathecode, client: APIClient): + model = bc.database.create(user=1) + client.force_authenticate(model.user) + + url = reverse_lazy("notify:slack_team") + response = client.get(url) + + json = response.json() + expected = [] + assert json == expected + assert response.status_code == status.HTTP_200_OK + assert bc.database.list_of("notify.SlackTeam") == [] + + +def test_with_teams(bc: Breathecode, client: APIClient): + model = bc.database.create(user=1, slack_team=1) + client.force_authenticate(model.user) + + url = reverse_lazy("notify:slack_team") + response = client.get(url) + + json = response.json() + expected = [ + get_serializer(model.slack_team, data={"created_at": bc.datetime.to_iso_string(model.slack_team.created_at)}) + ] + + assert json == expected + assert response.status_code == status.HTTP_200_OK + assert bc.database.list_of("notify.SlackTeam") == [bc.format.to_dict(model.slack_team)] + + +def test_with_teams_filtering(bc: Breathecode, client: APIClient): + model = bc.database.create(user=1, academy={"slug": "hogwartz"}, slack_team=1) + client.force_authenticate(model.user) + + url = reverse_lazy("notify:slack_team") + "?academy=hogwartz" + response = client.get(url) + + json = response.json() + expected = [ + get_serializer(model.slack_team, data={"created_at": bc.datetime.to_iso_string(model.slack_team.created_at)}) + ] + + assert json == expected + assert response.status_code == status.HTTP_200_OK + assert bc.database.list_of("notify.SlackTeam") == [bc.format.to_dict(model.slack_team)] + + +def test_with_teams_filtering_no_academy(bc: Breathecode, client: APIClient): + model = bc.database.create(user=1, academy={"slug": "hogwartz"}, slack_team=1) + client.force_authenticate(model.user) + + url = reverse_lazy("notify:slack_team") + "?academy=adasdasd" + response = client.get(url) + + json = response.json() + expected = [] + + assert json == expected + assert response.status_code == status.HTTP_200_OK + assert bc.database.list_of("notify.SlackTeam") == [bc.format.to_dict(model.slack_team)] diff --git a/breathecode/notify/urls.py b/breathecode/notify/urls.py index e8b8752db..66da87b06 100644 --- a/breathecode/notify/urls.py +++ b/breathecode/notify/urls.py @@ -7,6 +7,7 @@ preview_slack_template, HooksView, get_sample_data, + SlackTeamsView, ) app_name = "notify" @@ -20,4 +21,5 @@ path("hook/sample", get_sample_data), path("hook//sample", get_sample_data), path("slack/command", slack_command, name="slack_command"), + path("slack/team", SlackTeamsView.as_view(), name="slack_team"), ] diff --git a/breathecode/notify/views.py b/breathecode/notify/views.py index 84bd135de..4d709be68 100644 --- a/breathecode/notify/views.py +++ b/breathecode/notify/views.py @@ -12,8 +12,8 @@ from capyc.rest_framework.exceptions import ValidationException from .actions import get_template_content -from .models import Hook -from .serializers import HookSerializer +from .models import Hook, SlackTeam +from .serializers import HookSerializer, SlackTeamSerializer from .tasks import async_slack_action, async_slack_command logger = logging.getLogger(__name__) @@ -194,3 +194,25 @@ def delete(self, request, hook_id=None): item.delete() return Response({"details": f"Unsubscribed from {total} hooks"}, status=status.HTTP_200_OK) + + +class SlackTeamsView(APIView, GenerateLookupsMixin): + """ + List all snippets, or create a new snippet. + """ + + extensions = APIViewExtensions(sort="-created_at", paginate=True) + + def get(self, request): + handler = self.extensions(request) + + items = SlackTeam.objects.all() + academy = request.GET.get("academy", None) + if academy is not None: + academy = academy.split(",") + items = items.filter(academy__slug__in=academy) + + items = handler.queryset(items) + serializer = SlackTeamSerializer(items, many=True) + + return handler.response(serializer.data) diff --git a/breathecode/payments/actions.py b/breathecode/payments/actions.py index ec42423ce..e00175682 100644 --- a/breathecode/payments/actions.py +++ b/breathecode/payments/actions.py @@ -679,6 +679,57 @@ def filter_consumables( return queryset +def filter_void_consumable_balance(request: WSGIRequest, items: QuerySet[Consumable]): + consumables = items.filter(service_item__service__type="VOID") + + if ids := request.GET.get("service"): + try: + ids = [int(x) for x in ids.split(",")] + except Exception: + raise ValidationException("service param must be integer") + + consumables = consumables.filter(service_item__service__id__in=ids) + + if slugs := request.GET.get("service_slug"): + slugs = slugs.split(",") + + consumables = consumables.filter(service_item__service__slug__in=slugs) + + if not consumables: + return [] + + result = {} + + for consumable in consumables: + service = consumable.service_item.service + if service.id not in result: + result[service.id] = { + "balance": { + "unit": 0, + }, + "id": service.id, + "slug": service.slug, + "items": [], + } + + if consumable.how_many <= 0: + result[service.id]["balance"]["unit"] = -1 + + elif result[service.id]["balance"]["unit"] != -1: + result[service.id]["balance"]["unit"] += consumable.how_many + + result[service.id]["items"].append( + { + "id": consumable.id, + "how_many": consumable.how_many, + "unit_type": consumable.unit_type, + "valid_until": consumable.valid_until, + } + ) + + return result.values() + + def get_balance_by_resource(queryset: QuerySet, key: str): result = [] diff --git a/breathecode/payments/tests/urls/tests_me_service_consumable.py b/breathecode/payments/tests/urls/tests_me_service_consumable.py index 3136ab93d..3522a093b 100644 --- a/breathecode/payments/tests/urls/tests_me_service_consumable.py +++ b/breathecode/payments/tests/urls/tests_me_service_consumable.py @@ -142,6 +142,7 @@ def test__without_consumables(self): "mentorship_service_sets": [], "cohort_sets": [], "event_type_sets": [], + "voids": [], } assert json == expected @@ -166,6 +167,7 @@ def test__one_consumable__how_many_is_zero(self): "mentorship_service_sets": [], "cohort_sets": [], "event_type_sets": [], + "voids": [], } assert json == expected @@ -229,6 +231,7 @@ def test__nine_consumables__random_how_many__related_to_three_cohorts__without_c }, ], "event_type_sets": [], + "voids": [], } assert json == expected @@ -256,6 +259,7 @@ def test__nine_consumables__random_how_many__related_to_three_cohorts__with_wron "mentorship_service_sets": [], "cohort_sets": [], "event_type_sets": [], + "voids": [], } assert json == expected @@ -313,6 +317,7 @@ def test__nine_consumables__random_how_many__related_to_three_cohorts__with_coho }, ], "event_type_sets": [], + "voids": [], } assert json == expected @@ -377,6 +382,7 @@ def test__nine_consumables__related_to_three_mentorship_services__without_cohort ], "cohort_sets": [], "event_type_sets": [], + "voids": [], } assert json == expected @@ -404,6 +410,7 @@ def test__nine_consumables__related_to_three_mentorship_services__with_wrong_coh "cohort_sets": [], "mentorship_service_sets": [], "event_type_sets": [], + "voids": [], } assert json == expected @@ -463,6 +470,7 @@ def test__nine_consumables__related_to_three_mentorship_services__with_cohorts_i }, ], "event_type_sets": [], + "voids": [], } assert json == expected @@ -537,6 +545,7 @@ def test__nine_consumables__related_to_three_event_types__without_cohorts_in_que "items": [serialize_consumable(model.consumable[n]) for n in range(9)], }, ], + "voids": [], } assert json == expected @@ -574,6 +583,7 @@ def test__nine_consumables__related_to_three_event_types__with_wrong_cohorts_in_ "cohort_sets": [], "event_type_sets": [], "mentorship_service_sets": [], + "voids": [], } assert json == expected @@ -643,6 +653,7 @@ def test__nine_consumables__related_to_three_event_types__with_cohorts_in_querys }, ], "mentorship_service_sets": [], + "voids": [], } assert json == expected @@ -707,6 +718,7 @@ def test__nine_consumables__random_how_many__related_to_three_cohorts__without_c }, ], "event_type_sets": [], + "voids": [], } assert json == expected @@ -734,6 +746,7 @@ def test__nine_consumables__random_how_many__related_to_three_cohorts__with_wron "mentorship_service_sets": [], "cohort_sets": [], "event_type_sets": [], + "voids": [], } assert json == expected @@ -796,6 +809,7 @@ def test__nine_consumables__random_how_many__related_to_three_cohorts__with_coho }, ], "event_type_sets": [], + "voids": [], } assert json == expected @@ -859,6 +873,7 @@ def test__nine_consumables__related_to_three_mentorship_services__without_cohort ], "cohort_sets": [], "event_type_sets": [], + "voids": [], } assert json == expected @@ -886,6 +901,7 @@ def test__nine_consumables__related_to_three_mentorship_services__with_wrong_coh "cohort_sets": [], "mentorship_service_sets": [], "event_type_sets": [], + "voids": [], } assert json == expected @@ -948,6 +964,7 @@ def test__nine_consumables__related_to_three_mentorship_services__with_cohort_sl }, ], "event_type_sets": [], + "voids": [], } assert json == expected @@ -1021,6 +1038,7 @@ def test__nine_consumables__related_to_three_event_types__without_cohort_slugs_i "items": [serialize_consumable(model.consumable[n]) for n in range(9)], }, ], + "voids": [], } assert json == expected @@ -1058,6 +1076,7 @@ def test__nine_consumables__related_to_three_event_types__with_wrong_cohort_slug "cohort_sets": [], "event_type_sets": [], "mentorship_service_sets": [], + "voids": [], } assert json == expected @@ -1130,6 +1149,7 @@ def test__nine_consumables__related_to_three_event_types__with_cohort_slugs_in_q }, ], "mentorship_service_sets": [], + "voids": [], } assert json == expected @@ -1138,3 +1158,164 @@ def test__nine_consumables__related_to_three_event_types__with_cohort_slugs_in_q self.bc.database.list_of("payments.Consumable"), self.bc.format.to_dict(model.consumable), ) + + """ + 🔽🔽🔽 Get with nine Consumable and three Services, random how_many + """ + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__nine_consumables__related_to_three_services__without_cohort_slugs_in_querystring(self): + consumables = [{"how_many": random.randint(1, 30), "service_item_id": math.floor(n / 3) + 1} for n in range(9)] + service_items = [{"service_id": n + 1} for n in range(3)] + + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + + model = self.bc.database.create( + user=1, + consumable=consumables, + service=(3, {"type": "VOID"}), + service_item=service_items, + ) + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + serialized_consumables = [serialize_consumable(model.consumable[n]) for n in range(9)] + expected = { + "mentorship_service_sets": [], + "cohort_sets": [], + "event_type_sets": [], + "voids": [ + { + "balance": { + "unit": how_many_belong_to1, + }, + "id": model.service[0].id, + "slug": model.service[0].slug, + "items": serialized_consumables[:3], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.service[1].id, + "slug": model.service[1].slug, + "items": serialized_consumables[3:6], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.service[2].id, + "slug": model.service[2].slug, + "items": serialized_consumables[6:9], + }, + ], + } + + assert json == expected + assert response.status_code == status.HTTP_200_OK + assert self.bc.database.list_of("payments.Consumable") == self.bc.format.to_dict(model.consumable) + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__nine_consumables__related_to_three_services__with_wrong_cohort_slugs_in_querystring(self): + consumables = [{"how_many": random.randint(1, 30), "service_item_id": math.floor(n / 3) + 1} for n in range(9)] + service_items = [{"service_id": n + 1} for n in range(3)] + + model = self.bc.database.create( + user=1, + consumable=consumables, + service=(3, {"type": "VOID"}), + service_item=service_items, + ) + self.client.force_authenticate(model.user) + + url = reverse_lazy("payments:me_service_consumable") + f"?service_slug=blabla1,blabla2,blabla3" + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + expected = { + "cohort_sets": [], + "event_type_sets": [], + "mentorship_service_sets": [], + "voids": [], + } + + assert json == expected + assert response.status_code == status.HTTP_200_OK + assert self.bc.database.list_of("payments.Consumable") == self.bc.format.to_dict(model.consumable) + + @patch("django.utils.timezone.now", MagicMock(return_value=UTC_NOW)) + def test__nine_consumables__related_to_three_services__with_cohort_slugs_in_querystring(self): + consumables = [{"how_many": random.randint(1, 30), "service_item_id": math.floor(n / 3) + 1} for n in range(9)] + service_items = [{"service_id": n + 1} for n in range(3)] + belong_to1 = consumables[:3] + belong_to2 = consumables[3:6] + belong_to3 = consumables[6:] + + how_many_belong_to1 = sum([x["how_many"] for x in belong_to1]) + how_many_belong_to2 = sum([x["how_many"] for x in belong_to2]) + how_many_belong_to3 = sum([x["how_many"] for x in belong_to3]) + + model = self.bc.database.create( + user=1, + consumable=consumables, + service=(3, {"type": "VOID"}), + service_item=service_items, + ) + self.client.force_authenticate(model.user) + + url = ( + reverse_lazy("payments:me_service_consumable") + + f'?service_slug={",".join([x.slug for x in model.service])}' + ) + response = self.client.get(url) + self.client.force_authenticate(model.user) + + json = response.json() + serialized_consumables = [serialize_consumable(model.consumable[n]) for n in range(9)] + expected = { + "cohort_sets": [], + "event_type_sets": [], + "mentorship_service_sets": [], + "voids": [ + { + "balance": { + "unit": how_many_belong_to1, + }, + "id": model.service[0].id, + "slug": model.service[0].slug, + "items": serialized_consumables[:3], + }, + { + "balance": { + "unit": how_many_belong_to2, + }, + "id": model.service[1].id, + "slug": model.service[1].slug, + "items": serialized_consumables[3:6], + }, + { + "balance": { + "unit": how_many_belong_to3, + }, + "id": model.service[2].id, + "slug": model.service[2].slug, + "items": serialized_consumables[6:9], + }, + ], + } + + assert json == expected + assert response.status_code == status.HTTP_200_OK + assert self.bc.database.list_of("payments.Consumable") == self.bc.format.to_dict(model.consumable) diff --git a/breathecode/payments/views.py b/breathecode/payments/views.py index edf631f8c..24a8b39da 100644 --- a/breathecode/payments/views.py +++ b/breathecode/payments/views.py @@ -21,6 +21,7 @@ PlanFinder, add_items_to_bag, filter_consumables, + filter_void_consumable_balance, get_amount, get_amount_by_chosen_period, get_available_coupons, @@ -41,13 +42,13 @@ FinancialReputation, Invoice, MentorshipServiceSet, + PaymentMethod, Plan, PlanFinancing, PlanOffer, Service, ServiceItem, Subscription, - PaymentMethod, ) from breathecode.payments.serializers import ( GetAcademyServiceSmallSerializer, @@ -59,6 +60,7 @@ GetInvoiceSmallSerializer, GetMentorshipServiceSetSerializer, GetMentorshipServiceSetSmallSerializer, + GetPaymentMethod, GetPlanFinancingSerializer, GetPlanOfferSerializer, GetPlanSerializer, @@ -69,7 +71,6 @@ POSTAcademyServiceSerializer, PUTAcademyServiceSerializer, ServiceSerializer, - GetPaymentMethod, ) from breathecode.payments.services.stripe import Stripe from breathecode.payments.signals import reimburse_service_units @@ -597,6 +598,7 @@ def get(self, request): "mentorship_service_sets": get_balance_by_resource(mentorship_services, "mentorship_service_set"), "cohort_sets": get_balance_by_resource(cohorts, "cohort_set"), "event_type_sets": get_balance_by_resource(event_types, "event_type_set"), + "voids": filter_void_consumable_balance(request, items), } return Response(balance)