From e59e4e830ef2ed25016c030694ff25d507dd9ae9 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Thu, 13 Jun 2024 19:12:36 -0500 Subject: [PATCH 1/4] add warnings to bills --- breathecode/provisioning/actions.py | 109 ++++++++---------- breathecode/provisioning/models.py | 2 + breathecode/provisioning/tasks.py | 5 +- .../templates/provisioning_invoice.html | 31 +++-- .../tasks/tests_calculate_bill_amounts.py | 16 +-- .../provisioning/tests/tasks/tests_upload.py | 14 ++- .../tests/urls/tests_bill_html.py | 5 +- 7 files changed, 89 insertions(+), 93 deletions(-) diff --git a/breathecode/provisioning/actions.py b/breathecode/provisioning/actions.py index e7d78df2e..b8af02872 100644 --- a/breathecode/provisioning/actions.py +++ b/breathecode/provisioning/actions.py @@ -316,7 +316,7 @@ def add_codespaces_activity(context: ActivityContext, field: dict, position: int academies = random.choices(academies, k=1) errors = [] - ignores = [] + warnings = [] logs = {} provisioning_bills = {} provisioning_vendor = None @@ -354,28 +354,10 @@ def add_codespaces_activity(context: ActivityContext, field: dict, position: int provisioning_bills[academy.id] = provisioning_bill date = datetime.strptime(field['Date'], '%Y-%m-%d') - for academy_id in logs.keys(): - for log in logs[academy_id]: - if (log['storage_action'] == 'DELETE' and log['storage_status'] == 'SYNCHED' - and log['starting_at'] <= pytz.utc.localize(date) <= log['ending_at']): - provisioning_bills.pop(academy_id, None) - ignores.append(f'User {field["Username"]} was deleted from the academy during this event at {date}') - - if not provisioning_bills: - for academy_id in logs.keys(): - cohort_user = CohortUser.objects.filter( - Q(cohort__ending_date__lte=date) | Q(cohort__never_ends=True), - cohort__kickoff_date__gte=date, - cohort__academy__id=academy_id, - user__credentialsgithub__username=field['Username']).order_by('-created_at').first() - - if cohort_user: - errors.append('We found activity from this user while he was studying at one of your cohort ' - f'{cohort_user.cohort.slug}') if not_found: - errors.append(f'We could not find enough information about {field["Username"]}, mark this user user as ' - 'deleted if you don\'t recognize it') + warnings.append(f'We could not find enough information about {field["Username"]}, mark this user user as ' + 'deleted if you don\'t recognize it') if not (kind := context['provisioning_activity_kinds'].get((field['Product'], field['SKU']), None)): kind, _ = ProvisioningConsumptionKind.objects.get_or_create( @@ -415,19 +397,22 @@ def add_codespaces_activity(context: ActivityContext, field: dict, position: int csv_row=position, ) - if errors and not (len(errors) == 1 and not_found): + last_status_list = [x for x in pa.status_text.split(', ') if x] + if errors: pa.status = 'ERROR' - pa.status_text = pa.status_text + (', ' if pa.status_text else '') + ', '.join(errors + ignores) + pa.status_text = ', '.join(last_status_list + errors + warnings) + + elif warnings: + if pa.status != 'ERROR': + pa.status = 'WARNING' - elif pa.status != 'ERROR' and ignores and not provisioning_bills: - pa.status = 'IGNORED' - pa.status_text = pa.status_text + (', ' if pa.status_text else '') + ', '.join(ignores) + pa.status_text = ', '.join(last_status_list + warnings) else: pa.status = 'PERSISTED' - pa.status_text = pa.status_text + (', ' if pa.status_text else '') + ', '.join(errors + ignores) + pa.status_text = ', '.join(last_status_list + errors + warnings) - pa.status_text = ', '.join(sorted(set(pa.status_text.split(', ')))) + pa.status_text = ', '.join([x for x in sorted(set(pa.status_text.split(', '))) if x]) pa.status_text = pa.status_text[:255] pa.save() @@ -469,13 +454,14 @@ def add_gitpod_activity(context: ActivityContext, field: dict, position: int): academies = list(context['academies']) errors = [] + warnings = [] if not academies: - errors.append(f'We could not find enough information about {field["userName"]}, mark this user user as ' - 'deleted if you don\'t recognize it') + warnings.append(f'We could not find enough information about {field["userName"]}, mark this user user as ' + 'deleted if you don\'t recognize it') pattern = r'^https://github\.com/[^/]+/([^/]+)/?' if not (result := re.findall(pattern, field['contextURL'])): - errors.append(f'Invalid repository URL {field["contextURL"]}') + warnings.append(f'Invalid repository URL {field["contextURL"]}') slug = 'unknown' else: @@ -556,9 +542,22 @@ def add_gitpod_activity(context: ActivityContext, field: dict, position: int): if pa.status == 'PENDING': pa.status = 'PERSISTED' if not errors else 'ERROR' - pa.status_text = pa.status_text + (', ' if pa.status_text else '') + ', '.join(errors) + last_status_list = [x for x in pa.status_text.split(', ') if x] + if errors: + pa.status = 'ERROR' + pa.status_text = ', '.join(last_status_list + errors + warnings) + + elif warnings: + if pa.status != 'ERROR': + pa.status = 'WARNING' + + pa.status_text = ', '.join(last_status_list + warnings) - pa.status_text = ', '.join(sorted(set(pa.status_text.split(', ')))) + else: + pa.status = 'PERSISTED' + pa.status_text = ', '.join(last_status_list + errors + warnings) + + pa.status_text = ', '.join([x for x in sorted(set(pa.status_text.split(', '))) if x]) pa.status_text = pa.status_text[:255] pa.save() @@ -572,7 +571,7 @@ def add_gitpod_activity(context: ActivityContext, field: dict, position: int): def add_rigobot_activity(context: ActivityContext, field: dict, position: int) -> None: errors = [] - ignores = [] + warnings = [] if field['organization'] != '4Geeks': return @@ -657,31 +656,11 @@ def add_rigobot_activity(context: ActivityContext, field: dict, position: int) - context['provisioning_bills'][academy.id] = provisioning_bill provisioning_bills[academy.id] = provisioning_bill - for academy_id in logs.keys(): - for log in logs[academy_id]: - if (log['storage_action'] == 'DELETE' and log['storage_status'] == 'SYNCHED' - and log['starting_at'] <= pytz.utc.localize(date) <= log['ending_at']): - provisioning_bills.pop(academy_id, None) - ignores.append( - f'User {field["github_username"]} was deleted from the academy during this event at {date}') - - # disabled because rigobot doesn't have the organization configured yet. - # if not provisioning_bills: - # for academy_id in logs.keys(): - # cohort_user = CohortUser.objects.filter( - # Q(cohort__ending_date__lte=date) | Q(cohort__never_ends=True), - # cohort__kickoff_date__gte=date, - # cohort__academy__id=academy_id, - # user__credentialsgithub__username=field['github_username']).order_by('-created_at').first() - - # if cohort_user: - # errors.append('We found activity from this user while he was studying at one of your cohort ' - # f'{cohort_user.cohort.slug}') - # not implemented yet if not_found: - errors.append(f'We could not find enough information about {field["github_username"]}, mark this user user as ' - 'deleted if you don\'t recognize it') + warnings.append( + f'We could not find enough information about {field["github_username"]}, mark this user user as ' + 'deleted if you don\'t recognize it') s_slug = f'{field["purpose_slug"] or "no-provided"}--{field["pricing_type"].lower()}--{field["model"].lower()}' s_name = f'{field["purpose"]} (type: {field["pricing_type"]}, model: {field["model"]})' @@ -723,20 +702,22 @@ def add_rigobot_activity(context: ActivityContext, field: dict, position: int) - csv_row=position, ) - # if errors and not (len(errors) == 1 and not_found): + last_status_list = [x for x in pa.status_text.split(', ') if x] if errors: pa.status = 'ERROR' - pa.status_text = pa.status_text + (', ' if pa.status_text else '') + ', '.join(errors + ignores) + pa.status_text = ', '.join(last_status_list + errors + warnings) + + elif warnings: + if pa.status != 'ERROR': + pa.status = 'WARNING' - elif pa.status != 'ERROR' and ignores and not provisioning_bills: - pa.status = 'IGNORED' - pa.status_text = pa.status_text + (', ' if pa.status_text else '') + ', '.join(ignores) + pa.status_text = ', '.join(last_status_list + warnings) else: pa.status = 'PERSISTED' - pa.status_text = pa.status_text + (', ' if pa.status_text else '') + ', '.join(errors + ignores) + pa.status_text = ', '.join(last_status_list + errors + warnings) - pa.status_text = ', '.join(sorted(set(pa.status_text.split(', ')))) + pa.status_text = ', '.join([x for x in sorted(set(pa.status_text.split(', '))) if x]) pa.status_text = pa.status_text[:255] pa.save() diff --git a/breathecode/provisioning/models.py b/breathecode/provisioning/models.py index 3a2a8a17b..084c8d9c1 100644 --- a/breathecode/provisioning/models.py +++ b/breathecode/provisioning/models.py @@ -138,10 +138,12 @@ def __str__(self): PENDING = 'PENDING' PERSISTED = 'PERSISTED' +WARNING = 'WARNING' ACTIVITY_STATUS = ( (PENDING, 'Pending'), (PERSISTED, 'Persisted'), (IGNORED, 'Ignored'), + (WARNING, 'Warning'), (ERROR, 'Error'), ) diff --git a/breathecode/provisioning/tasks.py b/breathecode/provisioning/tasks.py index 46d1ac8d6..25c4d0d0c 100644 --- a/breathecode/provisioning/tasks.py +++ b/breathecode/provisioning/tasks.py @@ -99,7 +99,7 @@ def calculate_bill_amounts(hash: str, *, force: bool = False, **_: Any): for bill in bills: amount = 0 - for activity in ProvisioningUserConsumption.objects.filter(bills=bill, status='PERSISTED'): + for activity in ProvisioningUserConsumption.objects.filter(bills=bill, status__in=['PERSISTED', 'WARNING']): consumption_amount = 0 consumption_quantity = 0 for item in activity.events.all(): @@ -242,6 +242,9 @@ def upload(hash: str, *, page: int = 0, force: bool = False, task_manager_id: in elif not ProvisioningUserConsumption.objects.filter(hash=hash, status='ERROR').exists(): calculate_bill_amounts.delay(hash) + elif ProvisioningUserConsumption.objects.filter(hash=hash, status='ERROR').exists(): + ProvisioningBill.objects.filter(hash=hash).update(status='ERROR') + @task(priority=TaskPriority.BACKGROUND.value) def archive_provisioning_bill(bill_id: int, **_: Any): diff --git a/breathecode/provisioning/templates/provisioning_invoice.html b/breathecode/provisioning/templates/provisioning_invoice.html index 369fca1c6..773544d8d 100644 --- a/breathecode/provisioning/templates/provisioning_invoice.html +++ b/breathecode/provisioning/templates/provisioning_invoice.html @@ -2,9 +2,8 @@ {% load math %} {% block head %} - + {% endblock %} {% block content %} @@ -121,7 +120,6 @@ .alert td { width: 100%; } - @@ -160,7 +158,7 @@
Created At: {{ bill.created_at }}
{% if bill.stripe_url %} - Pay + Pay {% endif %}
@@ -189,10 +187,10 @@
{{ consumption.kind.product_name }} ({{ consumption.kind.sku }}) {% if consumption.status_text %} - - - - see error - + - + + show errors + {% endif %} @@ -200,9 +198,9 @@
{% if consumption.amount.is_integer %} - {{ consumption.amount|floatformat:0 }} + {{ consumption.amount|floatformat:0 }} {% else %} - {{ consumption.amount|floatformat:2 }} + {{ consumption.amount|floatformat:2 }} {% endif %} @@ -222,14 +220,15 @@
- {% if page < pages %} - - {% endif %} + + {% endif %} - + {% endblock %} diff --git a/breathecode/provisioning/tests/tasks/tests_calculate_bill_amounts.py b/breathecode/provisioning/tests/tasks/tests_calculate_bill_amounts.py index 996d41c6a..77d27aa6c 100644 --- a/breathecode/provisioning/tests/tasks/tests_calculate_bill_amounts.py +++ b/breathecode/provisioning/tests/tasks/tests_calculate_bill_amounts.py @@ -1,19 +1,21 @@ """ Test /answer/:id """ -from datetime import datetime, timedelta +import logging import math import os import random import re +from datetime import datetime, timedelta +from unittest.mock import MagicMock, PropertyMock, call, patch + +import pandas as pd from django.utils import timezone from faker import Faker -import pandas as pd from pytz import UTC -from breathecode.provisioning.tasks import calculate_bill_amounts -import logging -from unittest.mock import PropertyMock, patch, MagicMock, call + from breathecode.payments.services.stripe import Stripe +from breathecode.provisioning.tasks import calculate_bill_amounts from ..mixins import ProvisioningTestCase @@ -252,7 +254,7 @@ def test_bill_exists_and_activities__gitpod(self): } for n in range(2)] provisioning_user_consumptions = [{ - 'status': 'PERSISTED', + 'status': random.choice(['PERSISTED', 'WARNING']), } for _ in range(2)] amount = sum([ @@ -333,7 +335,7 @@ def test_bill_exists_and_activities__codespaces(self): } for n in range(2)] provisioning_user_consumptions = [{ - 'status': 'PERSISTED', + 'status': random.choice(['PERSISTED', 'WARNING']), } for _ in range(2)] amount = sum([ diff --git a/breathecode/provisioning/tests/tasks/tests_upload.py b/breathecode/provisioning/tests/tasks/tests_upload.py index af05b61ab..8ea0aa0b3 100644 --- a/breathecode/provisioning/tests/tasks/tests_upload.py +++ b/breathecode/provisioning/tests/tasks/tests_upload.py @@ -670,6 +670,7 @@ def test_users_not_found__case1(self): 'vendor_id': None, 'hash': slug, 'total_amount': 0.0, + 'status': 'ERROR', }) for n in range(20) ]) self.assertEqual(self.bc.database.list_of('provisioning.ProvisioningConsumptionKind'), [ @@ -820,6 +821,7 @@ def test_users_not_found__case2(self): 'vendor_id': None, 'hash': slug, 'total_amount': 0.0, + 'status': 'ERROR', }) for n in range(20) ]) self.assertEqual(self.bc.database.list_of('provisioning.ProvisioningConsumptionKind'), [ @@ -1344,7 +1346,7 @@ def test_from_github_credentials__generate_anything__case1(self): 'processed_at': UTC_NOW, 'status': - 'PERSISTED', + 'WARNING', 'status_text': (f"We could not find enough information about {csv['Username'][n]}, mark this user user " "as deleted if you don't recognize it"), }) for n in range(10) @@ -1501,7 +1503,10 @@ def test_from_github_credentials__vendor_not_found(self): self.assertEqual(self.bc.database.list_of('payments.Currency'), [currency_data()]) self.assertEqual(self.bc.database.list_of('provisioning.ProvisioningBill'), [ - provisioning_bill_data({'hash': slug}), + provisioning_bill_data({ + 'hash': slug, + 'status': 'ERROR', + }), ]) self.assertEqual(self.bc.database.list_of('provisioning.ProvisioningConsumptionKind'), [ provisioning_activity_kind_data({ @@ -2342,7 +2347,10 @@ def test_from_github_credentials__vendor_not_found(self): self.assertEqual(self.bc.database.list_of('payments.Currency'), [currency_data()]) self.assertEqual(self.bc.database.list_of('provisioning.ProvisioningBill'), [ - provisioning_bill_data({'hash': slug}), + provisioning_bill_data({ + 'hash': slug, + 'status': 'ERROR', + }), ]) self.assertEqual(self.bc.database.list_of('provisioning.ProvisioningConsumptionKind'), [ provisioning_activity_kind_data( diff --git a/breathecode/provisioning/tests/urls/tests_bill_html.py b/breathecode/provisioning/tests/urls/tests_bill_html.py index b12c99f2f..3562eddaf 100644 --- a/breathecode/provisioning/tests/urls/tests_bill_html.py +++ b/breathecode/provisioning/tests/urls/tests_bill_html.py @@ -2,11 +2,12 @@ Test cases for /academy/:id/member/:id """ import os -import urllib.parse + from django.template import loader from django.urls.base import reverse_lazy -from rest_framework import status from django.utils import timezone +from rest_framework import status + from ..mixins import ProvisioningTestCase UTC_NOW = timezone.now() From 29ecff5ede01a7687d0496f2bd008433d2368278 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Thu, 13 Jun 2024 19:14:38 -0500 Subject: [PATCH 2/4] add migration --- ...lter_provisioninguserconsumption_status.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 breathecode/provisioning/migrations/0017_alter_provisioninguserconsumption_status.py diff --git a/breathecode/provisioning/migrations/0017_alter_provisioninguserconsumption_status.py b/breathecode/provisioning/migrations/0017_alter_provisioninguserconsumption_status.py new file mode 100644 index 000000000..7441534a4 --- /dev/null +++ b/breathecode/provisioning/migrations/0017_alter_provisioninguserconsumption_status.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.6 on 2024-06-14 00:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('provisioning', '0016_alter_provisioningconsumptionevent_repository_url_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='provisioninguserconsumption', + name='status', + field=models.CharField(choices=[('PENDING', 'Pending'), ('PERSISTED', 'Persisted'), ('IGNORED', 'Ignored'), + ('WARNING', 'Warning'), ('ERROR', 'Error')], + default='PENDING', + max_length=20), + ), + ] From c25f9ec7d196473d29710b505db576c0709f25b7 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Mon, 17 Jun 2024 17:08:16 -0500 Subject: [PATCH 3/4] add google meet provider --- breathecode/authenticate/urls.py | 17 +- breathecode/celery.py | 10 +- breathecode/mentorship/actions.py | 60 ++++- breathecode/mentorship/models.py | 162 ++++++++---- .../tests_get_pending_sessions_or_create.py | 182 ++++++++++++- .../tests/urls/tests_academy_service.py | 2 + .../tests/urls/tests_academy_service_id.py | 1 + .../tests_meet_slug_service_slug.py | 199 ++++++++++++-- breathecode/mentorship/views.py | 5 +- breathecode/services/__init__.py | 1 + breathecode/services/google_meet/__init__.py | 1 + .../services/google_meet/google_meet.py | 250 ++++++++++-------- breathecode/tests/mixins/datetime_mixin.py | 16 +- .../utils/create_models.py | 5 +- capyc/django/pytest/fixtures/database.py | 6 + 15 files changed, 687 insertions(+), 230 deletions(-) create mode 100644 breathecode/services/google_meet/__init__.py diff --git a/breathecode/authenticate/urls.py b/breathecode/authenticate/urls.py index 77d21d94d..267f79fb6 100644 --- a/breathecode/authenticate/urls.py +++ b/breathecode/authenticate/urls.py @@ -13,6 +13,8 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ +import os + from django.urls import path from linked_services.rest_framework.views import app_webhook, authorize_view @@ -67,6 +69,19 @@ sync_gitpod_users_view, ) +# avoiding issues on test environment due that the fixture are loaded after this app +ENV = os.getenv('ENV') +TEST_ENV = (ENV == 'test' or ENV not in ['development', 'staging', 'production']) +LOGIN_URL = '/v1/auth/view/login' +app_url = os.getenv('APP_URL') + +if TEST_ENV and not app_url: + import faker + + fake = faker.Faker() + + app_url = fake.url().replace('http://', 'https://') + app_name = 'authenticate' urlpatterns = [ path('confirmation/', ConfirmEmailView.as_view(), name='confirmation_token'), @@ -142,7 +157,7 @@ # authorize path('authorize/', - authorize_view(login_url='/v1/auth/view/login', get_language=get_user_language), + authorize_view(login_url=LOGIN_URL, app_url=app_url, get_language=get_user_language), name='authorize_slug'), # apps diff --git a/breathecode/celery.py b/breathecode/celery.py index 09302387a..6fc8a9910 100644 --- a/breathecode/celery.py +++ b/breathecode/celery.py @@ -7,7 +7,7 @@ # the rest of your Celery file contents go here import os -from datetime import datetime, timedelta +from datetime import UTC, datetime from typing import TypedDict from celery import Celery @@ -123,7 +123,7 @@ def __exit__(self, *args, **kwargs): if len(data[i]) >= 2: data[i].sort(key=lambda x: x['created_at']) - if datetime.utcnow() - data[i][-1]['created_at'] < delta: + if datetime.now(UTC) - data[i][-1]['created_at'] < delta: available[i] = False data[i] = data[i][-2:] else: @@ -131,7 +131,7 @@ def __exit__(self, *args, **kwargs): data[i] = data[i][-1:] elif len(data[i]) == 1: - if datetime.utcnow() - data[i][0]['created_at'] < delta: + if datetime.now(UTC) - data[i][0]['created_at'] < delta: available[i] = False else: available[i] = True @@ -142,7 +142,7 @@ def __exit__(self, *args, **kwargs): found = False for i in range(workers): if available[i]: - data[i].append({'pid': worker_id, 'created_at': datetime.utcnow()}) + data[i].append({'pid': worker_id, 'created_at': datetime.now(UTC)}) found = True break @@ -153,7 +153,7 @@ def __exit__(self, *args, **kwargs): if len(data[i]) < len(pointer): pointer = data[i] - pointer.append({'pid': worker_id, 'created_at': datetime.utcnow()}) + pointer.append({'pid': worker_id, 'created_at': datetime.now(UTC)}) cache.set('workers', data, timeout=None) break diff --git a/breathecode/mentorship/actions.py b/breathecode/mentorship/actions.py index aaaa87b65..204bcd54b 100644 --- a/breathecode/mentorship/actions.py +++ b/breathecode/mentorship/actions.py @@ -3,18 +3,21 @@ from datetime import timedelta import pytz +from asgiref.sync import sync_to_async from dateutil.relativedelta import relativedelta from django.db.models import Q, QuerySet from django.shortcuts import render from django.utils import timezone +from google.apps.meet_v2.types import Space, SpaceConfig import breathecode.activity.tasks as tasks_activity from breathecode.mentorship.exceptions import ExtendSessionException from breathecode.services.daily.client import DailyClient +from breathecode.services.google_meet.google_meet import GoogleMeet from breathecode.utils.datetime_integer import duration_to_str from capyc.rest_framework.exceptions import ValidationException -from .models import MentorProfile, MentorshipBill, MentorshipSession +from .models import MentorProfile, MentorshipBill, MentorshipService, MentorshipSession logger = logging.getLogger(__name__) @@ -91,12 +94,20 @@ def get_pending_sessions_or_create(token, mentor, service, mentee=None): is_online=True, service=service, ends_at=timezone.now() + duration) - daily = DailyClient() - room = daily.create_room(exp_in_seconds=service.duration.seconds) - session.online_meeting_url = room['url'] - session.name = room['name'] - session.mentee = mentee - session.save() + + if session.service.video_provider == MentorshipService.VideoProvider.GOOGLE_MEET: + create_room_on_google_meet(session) + + elif session.service.video_provider == MentorshipService.VideoProvider.DAILY: + daily = DailyClient() + room = daily.create_room(exp_in_seconds=service.duration.seconds) + session.online_meeting_url = room['url'] + session.name = room['name'] + session.mentee = mentee + session.save() + + else: + raise Exception('Invalid video provider') if mentee: tasks_activity.add_activity.delay(mentee.id, @@ -392,3 +403,38 @@ def mentor_is_ready(mentor: MentorProfile): raise Exception(f'Mentor {mentor.name} online meeting URL is failing.') return True + + +def create_room_on_google_meet(session: MentorshipSession) -> None: + """Create a room on google meet for a mentorship session.""" + + if isinstance(session, MentorshipSession) is False: + raise Exception('session argument must be a MentorshipSession') + + if session.service.video_provider != session.service.VideoProvider.GOOGLE_MEET: + raise Exception('Video provider must be Google Meet') + + if not session.service: + raise Exception('Mentorship session doesn\'t have a service associated with it') + + mentor = session.mentor + + meet = GoogleMeet() + if session.id is None: + session.save() + + title = (f'{session.service.name} {session.id} | ' + f'{mentor.user.first_name} {mentor.user.last_name}') + s = Space( + name=title, + config=SpaceConfig(access_type=SpaceConfig.AccessType.OPEN), + ) + space = meet.create_space(space=s) + session.online_meeting_url = space.meeting_uri + session.name = title + session.save() + + +@sync_to_async +def acreate_room_on_google_meet(session: MentorshipSession) -> None: + return create_room_on_google_meet(session) diff --git a/breathecode/mentorship/models.py b/breathecode/mentorship/models.py index 41c532011..385639717 100644 --- a/breathecode/mentorship/models.py +++ b/breathecode/mentorship/models.py @@ -12,73 +12,133 @@ from breathecode.notify.models import SlackChannel from breathecode.utils.validators.language import validate_language_code -# settings customizable for each academy -# class AcademySettings(models.Model): -# is_video_streaming_active = models.BooleanField(default=False) -# academy = models.OneToOneField(Academy, on_delete=models.CASCADE) -# @staticmethod -# def get(pk): -# settings = AcademySettings.objects.filter(academy__id=pk).first() -# # lets create the settings if they dont exist for this academy -# if settings is None: -# settings = AcademySettings.objects.create(academy=pk) -# return settings -# def warnings(self): -# # return a dictionary with a list of the fields and warning messages related to them -# # for example: { "is_video_streaming_active": "Please settup a video streaming" } -# return {} -# def errors(self): -# # return a dictionary with a list of the fields and errors messages related to them -# return {} - -DRAFT = 'DRAFT' -ACTIVE = 'ACTIVE' -UNLISTED = 'UNLISTED' -INNACTIVE = 'INNACTIVE' -MENTORSHIP_STATUS = ( - (DRAFT, 'Draft'), - (ACTIVE, 'Active'), - (UNLISTED, 'Unlisted'), - (INNACTIVE, 'Innactive'), -) + +class VideoProvider(models.TextChoices): + DAILY = ('DAILY', 'Daily') + GOOGLE_MEET = ('GOOGLE_MEET', 'Google Meet') + + +MENTORSHIP_SETTINGS = { + 'duration': timedelta(hours=1), + 'max_duration': timedelta(hours=2), + 'missed_meeting_duration': timedelta(minutes=10), + 'language': 'en', + 'allow_mentee_to_extend': True, + 'allow_mentors_to_extend': True, + 'video_provider': VideoProvider.GOOGLE_MEET, +} + + +class AcademyMentorshipSettings(models.Model): + VideoProvider = VideoProvider + + academy = models.OneToOneField(Academy, on_delete=models.CASCADE) + duration = models.DurationField(default=MENTORSHIP_SETTINGS['duration'], + help_text='Default duration for mentorship sessions of this service') + + max_duration = models.DurationField( + default=MENTORSHIP_SETTINGS['max_duration'], + help_text='Maximum allowed duration or extra time, make it 0 for unlimited meetings') + + missed_meeting_duration = models.DurationField( + default=MENTORSHIP_SETTINGS['missed_meeting_duration'], + help_text='Duration that will be paid when the mentee doesn\'t come to the session') + + language = models.CharField(max_length=5, + default=MENTORSHIP_SETTINGS['language'], + validators=[validate_language_code], + help_text='ISO 639-1 language code + ISO 3166-1 alpha-2 country code, e.g. en-US') + + allow_mentee_to_extend = models.BooleanField(default=MENTORSHIP_SETTINGS['allow_mentee_to_extend'], + help_text='If true, mentees will be able to extend mentorship session') + allow_mentors_to_extend = models.BooleanField( + default=MENTORSHIP_SETTINGS['allow_mentors_to_extend'], + help_text='If true, mentors will be able to extend mentorship session') + + video_provider = models.CharField(max_length=15, choices=VideoProvider, default=VideoProvider.GOOGLE_MEET) + + created_at = models.DateTimeField(auto_now_add=True, editable=False) + updated_at = models.DateTimeField(auto_now=True, editable=False) + + def __str__(self): + return self.academy.name + + def clean(self) -> None: + return super().clean() + + def save(self, **kwargs) -> None: + return super().save(**kwargs) class MentorshipService(models.Model): + VideoProvider = VideoProvider + + class Status(models.TextChoices): + DRAFT = ('DRAFT', 'Draft') + ACTIVE = ('ACTIVE', 'Active') + UNLISTED = ('UNLISTED', 'Unlisted') + INNACTIVE = ('INNACTIVE', 'Innactive') + slug = models.SlugField(max_length=150, unique=True) name = models.CharField(max_length=150) logo_url = models.CharField(max_length=150, default=None, blank=True, null=True) description = models.TextField(max_length=500, default=None, blank=True, null=True) - duration = models.DurationField(default=timedelta(hours=1), + duration = models.DurationField(default=None, + blank=True, help_text='Default duration for mentorship sessions of this service') max_duration = models.DurationField( - default=timedelta(hours=2), - help_text='Maximum allowed duration or extra time, make it 0 for unlimited meetings') + default=None, blank=True, help_text='Maximum allowed duration or extra time, make it 0 for unlimited meetings') missed_meeting_duration = models.DurationField( - default=timedelta(minutes=10), - help_text='Duration that will be paid when the mentee doesn\'t come to the session') + default=None, blank=True, help_text='Duration that will be paid when the mentee doesn\'t come to the session') - status = models.CharField(max_length=15, choices=MENTORSHIP_STATUS, default=DRAFT) + status = models.CharField(max_length=15, choices=Status, default=Status.DRAFT) language = models.CharField(max_length=5, - default='en', + default=None, + blank=True, validators=[validate_language_code], help_text='ISO 639-1 language code + ISO 3166-1 alpha-2 country code, e.g. en-US') - allow_mentee_to_extend = models.BooleanField(default=True, + allow_mentee_to_extend = models.BooleanField(blank=True, + default=None, help_text='If true, mentees will be able to extend mentorship session') allow_mentors_to_extend = models.BooleanField( - default=True, help_text='If true, mentors will be able to extend mentorship session') + default=None, blank=True, help_text='If true, mentors will be able to extend mentorship session') academy = models.ForeignKey(Academy, on_delete=models.CASCADE) + video_provider = models.CharField(max_length=15, default=None, choices=VideoProvider, blank=True) created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) def __str__(self): - return f'{self.name} ({self.id})' + return f'{self.name} ({self.slug})' + + def clean(self) -> None: + fetched = False + academy_settings = None + for field, value in MENTORSHIP_SETTINGS.items(): + current = getattr(self, field) + if current is None: + if fetched is False: + fetched = True + academy_settings = AcademyMentorshipSettings.objects.filter(academy=self.academy).first() + + if academy_settings: + academy_value = getattr(academy_settings, field) + setattr(self, field, academy_value) + + else: + setattr(self, field, value) + + return super().clean() + + def save(self, **kwargs) -> None: + self.full_clean() + return super().save(**kwargs) class SupportChannel(models.Model): @@ -91,13 +151,11 @@ class SupportChannel(models.Model): updated_at = models.DateTimeField(auto_now=True, editable=False) -INVITED = 'INVITED' -MENTOR_STATUS = ( - (INVITED, 'Invited'), - (ACTIVE, 'Active'), - (UNLISTED, 'Unlisted'), - (INNACTIVE, 'Innactive'), -) +class MentorStatus(models.TextChoices): + INVITED = ('INVITED', 'Invited') + ACTIVE = ('ACTIVE', 'Active') + UNLISTED = ('UNLISTED', 'Unlisted') + INNACTIVE = ('INNACTIVE', 'Innactive') class SupportAgent(models.Model): @@ -109,9 +167,9 @@ class SupportAgent(models.Model): unique=True, help_text='Used for inviting the user to become a support agent') status = models.CharField(max_length=15, - choices=MENTOR_STATUS, - default=INVITED, - help_text=f'Options are: {", ".join([key for key,label in MENTOR_STATUS])}') + choices=MentorStatus, + default=MentorStatus.INVITED, + help_text=f'Options are: {", ".join([key for key,label in MentorStatus.choices])}') email = models.CharField(blank=True, max_length=150, @@ -176,9 +234,9 @@ class MentorProfile(models.Model): help_text='What syllabis is this mentor going to be menting to?') status = models.CharField(max_length=15, - choices=MENTOR_STATUS, - default=INVITED, - help_text=f'Options are: {", ".join([key for key,label in MENTOR_STATUS])}') + choices=MentorStatus, + default=MentorStatus.INVITED, + help_text=f'Options are: {", ".join([key for key,label in MentorStatus.choices])}') email = models.CharField(blank=True, max_length=150, diff --git a/breathecode/mentorship/tests/actions/tests_get_pending_sessions_or_create.py b/breathecode/mentorship/tests/actions/tests_get_pending_sessions_or_create.py index 256070783..7dfb3b69e 100644 --- a/breathecode/mentorship/tests/actions/tests_get_pending_sessions_or_create.py +++ b/breathecode/mentorship/tests/actions/tests_get_pending_sessions_or_create.py @@ -53,19 +53,29 @@ def format_mentorship_session_attrs(attrs={}): } +class GoogleMeetMock: + + def __init__(self, meeting_uri='https://meet.google.com/fake'): + self.meeting_uri = meeting_uri + + +def get_title(pk, service, mentor) -> str: + return (f'{service.name} {pk} | {mentor.user.first_name} {mentor.user.last_name}') + + class GetOrCreateSessionTestSuite(MentorshipTestCase): @patch(REQUESTS_PATH['request'], apply_requests_request_mock([(200, daily_url, daily_payload)])) @patch('breathecode.mentorship.signals.mentorship_session_status.send', MagicMock()) @patch('django.utils.timezone.now', MagicMock(return_value=ENDS_AT)) @patch('breathecode.mentorship.actions.close_older_sessions', MagicMock()) - def test_create_session_mentor_first_no_previous_nothing(self): + def test_create_session_mentor_first_no_previous_nothing__daily(self): """ When the mentor gets into the room before the mentee if should create a room with status 'pending' """ - models = self.bc.database.create(mentor_profile=1, user=1, mentorship_service=1) + models = self.bc.database.create(mentor_profile=1, user=1, mentorship_service={'video_provider': 'DAILY'}) mentor = models.mentor_profile mentor_token, created = Token.get_or_create(mentor.user, token_type='permanent') @@ -91,6 +101,44 @@ def test_create_session_mentor_first_no_previous_nothing(self): self.assertEqual(actions.close_older_sessions.call_args_list, [call()]) + @patch.multiple('breathecode.services.google_meet.google_meet.GoogleMeet', + __init__=MagicMock(return_value=None), + create_space=MagicMock(return_value=GoogleMeetMock(meeting_uri='https://meet.google.com/fake'))) + @patch('breathecode.mentorship.signals.mentorship_session_status.send', MagicMock()) + @patch('django.utils.timezone.now', MagicMock(return_value=ENDS_AT)) + @patch('breathecode.mentorship.actions.close_older_sessions', MagicMock()) + def test_create_session_mentor_first_no_previous_nothing__google_meet(self): + """ + When the mentor gets into the room before the mentee + if should create a room with status 'pending' + """ + + models = self.bc.database.create(mentor_profile=1, user=1, mentorship_service={'video_provider': 'GOOGLE_MEET'}) + + mentor = models.mentor_profile + mentor_token, created = Token.get_or_create(mentor.user, token_type='permanent') + + pending_sessions = get_pending_sessions_or_create(mentor_token, mentor, models.mentorship_service, mentee=None) + + self.bc.check.queryset_of(pending_sessions, MentorshipSession) + self.bc.check.queryset_with_pks(pending_sessions, [1]) + + self.assertEqual(self.bc.database.list_of('mentorship.MentorshipSession'), [ + format_mentorship_session_attrs({ + 'id': 1, + 'status': 'PENDING', + 'mentor_id': 1, + 'mentee_id': None, + 'service_id': 1, + 'is_online': True, + 'name': get_title(1, models.mentorship_service, models.mentor_profile), + 'online_meeting_url': 'https://meet.google.com/fake', + 'ends_at': ENDS_AT + timedelta(seconds=3600), + }), + ]) + + self.assertEqual(actions.close_older_sessions.call_args_list, [call()]) + @patch(REQUESTS_PATH['request'], apply_requests_request_mock([(200, daily_url, daily_payload)])) @patch('breathecode.mentorship.signals.mentorship_session_status.send', MagicMock()) @patch('django.utils.timezone.now', MagicMock(return_value=ENDS_AT)) @@ -102,7 +150,9 @@ def test_create_session_mentor_first_previous_pending_without_mentee(self): """ mentorship_session = {'mentee_id': None} - models = self.bc.database.create(mentor_profile=1, mentorship_session=mentorship_session, mentorship_service=1) + models = self.bc.database.create(mentor_profile=1, + mentorship_session=mentorship_session, + mentorship_service={'video_provider': 'DAILY'}) mentor = models.mentor_profile mentor_token, created = Token.get_or_create(mentor.user, token_type='permanent') @@ -141,7 +191,7 @@ def test_create_session_mentor_first_previous_pending_with_mentee(self): models = self.bc.database.create(mentor_profile=1, user=1, mentorship_session=mentorship_session, - mentorship_service=1) + mentorship_service={'video_provider': 'DAILY'}) mentor = models.mentor_profile session = models.mentorship_session @@ -181,7 +231,7 @@ def test_create_session_mentor_first_started_without_mentee(self): models = self.bc.database.create(mentor_profile=1, user=1, mentorship_session=mentorship_session, - mentorship_service=1) + mentorship_service={'video_provider': 'DAILY'}) mentor = models.mentor_profile mentor_token, created = Token.get_or_create(mentor.user, token_type='permanent') @@ -209,13 +259,13 @@ def test_create_session_mentor_first_started_without_mentee(self): @patch('breathecode.mentorship.signals.mentorship_session_status.send', MagicMock()) @patch('django.utils.timezone.now', MagicMock(return_value=ENDS_AT)) @patch('breathecode.mentorship.actions.close_older_sessions', MagicMock()) - def test_create_session_mentee_first_no_previous_nothing(self): + def test_create_session_mentee_first_no_previous_nothing__daily(self): """ Mentee comes first, there is nothing previously created it should return a brand new sessions with started at already started """ - models = self.bc.database.create(mentor_profile=1, user=2, mentorship_service=1) + models = self.bc.database.create(mentor_profile=1, user=2, mentorship_service={'video_provider': 'DAILY'}) mentor = models.mentor_profile mentee = models.user[1] @@ -241,6 +291,44 @@ def test_create_session_mentee_first_no_previous_nothing(self): self.assertEqual(actions.close_older_sessions.call_args_list, [call()]) + @patch.multiple('breathecode.services.google_meet.google_meet.GoogleMeet', + __init__=MagicMock(return_value=None), + create_space=MagicMock(return_value=GoogleMeetMock(meeting_uri='https://meet.google.com/fake'))) + @patch('breathecode.mentorship.signals.mentorship_session_status.send', MagicMock()) + @patch('django.utils.timezone.now', MagicMock(return_value=ENDS_AT)) + @patch('breathecode.mentorship.actions.close_older_sessions', MagicMock()) + def test_create_session_mentee_first_no_previous_nothing__google_meet(self): + """ + Mentee comes first, there is nothing previously created + it should return a brand new sessions with started at already started + """ + + models = self.bc.database.create(mentor_profile=1, user=2, mentorship_service={'video_provider': 'GOOGLE_MEET'}) + mentor = models.mentor_profile + mentee = models.user[1] + + mentee_token, created = Token.get_or_create(mentee, token_type='permanent') + sessions = get_pending_sessions_or_create(mentee_token, mentor, models.mentorship_service, mentee) + + self.bc.check.queryset_of(sessions, MentorshipSession) + self.bc.check.queryset_with_pks(sessions, [1]) + + self.assertEqual(self.bc.database.list_of('mentorship.MentorshipSession'), [ + format_mentorship_session_attrs({ + 'id': 1, + 'status': 'PENDING', + 'mentor_id': 1, + 'mentee_id': 2, + 'service_id': 1, + 'is_online': True, + 'ends_at': ENDS_AT + timedelta(seconds=3600), + 'name': get_title(1, models.mentorship_service, models.mentor_profile), + 'online_meeting_url': 'https://meet.google.com/fake', + }), + ]) + + self.assertEqual(actions.close_older_sessions.call_args_list, [call()]) + @patch(REQUESTS_PATH['request'], apply_requests_request_mock([(200, daily_url, daily_payload)])) @patch('breathecode.mentorship.signals.mentorship_session_status.send', MagicMock()) @patch('django.utils.timezone.now', MagicMock(return_value=ENDS_AT)) @@ -255,7 +343,7 @@ def test_create_session_mentee_first_with_wihout_mentee(self): models = self.bc.database.create(mentor_profile=1, user=2, mentorship_session=mentorship_session, - mentorship_service=1) + mentorship_service={'video_provider': 'DAILY'}) new_mentee = models.user[1] mentee_token, created = Token.get_or_create(new_mentee, token_type='permanent') @@ -284,7 +372,7 @@ def test_create_session_mentee_first_with_wihout_mentee(self): @patch('breathecode.mentorship.signals.mentorship_session_status.send', MagicMock()) @patch('django.utils.timezone.now', MagicMock(return_value=ENDS_AT)) @patch('breathecode.mentorship.actions.close_older_sessions', MagicMock()) - def test_create_session_mentee_first_with_another_mentee(self): + def test_create_session_mentee_first_with_another_mentee__daily(self): """ Mentee comes first, there is a previous pending meeting with another mentee it should keep and ignore old one (untouched) and create and return new one for this mentee @@ -293,12 +381,15 @@ def test_create_session_mentee_first_with_another_mentee(self): # other random mentoring session precreated just for better testing mentorship_session = {'status': 'PENDING'} - self.bc.database.create(mentor_profile=1, user=1, mentorship_session=mentorship_session, mentorship_service=1) + self.bc.database.create(mentor_profile=1, + user=1, + mentorship_session=mentorship_session, + mentorship_service={'video_provider': 'DAILY'}) models = self.bc.database.create(mentor_profile=1, user=1, mentorship_session=mentorship_session, - mentorship_service=1) + mentorship_service={'video_provider': 'DAILY'}) new_mentee = self.bc.database.create(user=1).user mentee_token, created = Token.get_or_create(new_mentee, token_type='permanent') @@ -340,6 +431,71 @@ def test_create_session_mentee_first_with_another_mentee(self): self.assertEqual(actions.close_older_sessions.call_args_list, [call()]) + @patch.multiple('breathecode.services.google_meet.google_meet.GoogleMeet', + __init__=MagicMock(return_value=None), + create_space=MagicMock(return_value=GoogleMeetMock(meeting_uri='https://meet.google.com/fake'))) + @patch('breathecode.mentorship.signals.mentorship_session_status.send', MagicMock()) + @patch('django.utils.timezone.now', MagicMock(return_value=ENDS_AT)) + @patch('breathecode.mentorship.actions.close_older_sessions', MagicMock()) + def test_create_session_mentee_first_with_another_mentee__google_meet(self): + """ + Mentee comes first, there is a previous pending meeting with another mentee + it should keep and ignore old one (untouched) and create and return new one for this mentee + """ + + # other random mentoring session precreated just for better testing + + mentorship_session = {'status': 'PENDING'} + self.bc.database.create(mentor_profile=1, + user=1, + mentorship_session=mentorship_session, + mentorship_service={'video_provider': 'DAILY'}) + + models = self.bc.database.create(mentor_profile=1, + user=1, + mentorship_session=mentorship_session, + mentorship_service={'video_provider': 'GOOGLE_MEET'}) + new_mentee = self.bc.database.create(user=1).user + + mentee_token, created = Token.get_or_create(new_mentee, token_type='permanent') + sessions_to_render = get_pending_sessions_or_create(mentee_token, + models.mentor_profile, + models.mentorship_service, + mentee=new_mentee) + + self.bc.check.queryset_of(sessions_to_render, MentorshipSession) + self.bc.check.queryset_with_pks(sessions_to_render, [3]) + + self.assertEqual(self.bc.database.list_of('mentorship.MentorshipSession'), [ + format_mentorship_session_attrs({ + 'id': 1, + 'ends_at': None, + 'mentee_id': 1, + 'mentor_id': 1, + 'service_id': 1, + }), + format_mentorship_session_attrs({ + 'id': 2, + 'ends_at': None, + 'mentee_id': 2, + 'mentor_id': 2, + 'service_id': 2, + }), + format_mentorship_session_attrs({ + 'id': 3, + 'status': 'PENDING', + 'mentor_id': 2, + 'mentee_id': 3, + 'is_online': True, + 'ends_at': ENDS_AT + timedelta(seconds=3600), + 'name': get_title(3, models.mentorship_service, models.mentor_profile), + 'online_meeting_url': 'https://meet.google.com/fake', + 'service_id': 2, + }), + ]) + + self.assertEqual(actions.close_older_sessions.call_args_list, [call()]) + @patch(REQUESTS_PATH['request'], apply_requests_request_mock([(200, daily_url, daily_payload)])) @patch('breathecode.mentorship.signals.mentorship_session_status.send', MagicMock()) @patch('django.utils.timezone.now', MagicMock(return_value=ENDS_AT)) @@ -354,13 +510,13 @@ def test_create_session_mentee_first_with_another_same_mentee(self): self.bc.database.create(mentor_profile=1, user=1, mentorship_session={'status': 'PENDING'}, - mentorship_service=1) + mentorship_service={'video_provider': 'DAILY'}) # old meeting with SAME mentee, should be re-used models = self.bc.database.create(mentor_profile=1, user=1, mentorship_session={'status': 'PENDING'}, - mentorship_service=1) + mentorship_service={'video_provider': 'DAILY'}) same_mentee = models.user mentee_token, created = Token.get_or_create(same_mentee, token_type='permanent') diff --git a/breathecode/mentorship/tests/urls/tests_academy_service.py b/breathecode/mentorship/tests/urls/tests_academy_service.py index c95b0181c..e5502a12d 100644 --- a/breathecode/mentorship/tests/urls/tests_academy_service.py +++ b/breathecode/mentorship/tests/urls/tests_academy_service.py @@ -52,6 +52,7 @@ def post_serializer(data={}): 'name': '', 'slug': '', 'status': 'DRAFT', + 'video_provider': 'GOOGLE_MEET', **data, } @@ -71,6 +72,7 @@ def mentorship_service_columns(data={}): 'name': '', 'slug': '', 'status': 'DRAFT', + 'video_provider': 'GOOGLE_MEET', **data, } diff --git a/breathecode/mentorship/tests/urls/tests_academy_service_id.py b/breathecode/mentorship/tests/urls/tests_academy_service_id.py index 59939d353..ce081fc58 100644 --- a/breathecode/mentorship/tests/urls/tests_academy_service_id.py +++ b/breathecode/mentorship/tests/urls/tests_academy_service_id.py @@ -85,6 +85,7 @@ def mentorship_service_columns(mentorship_service, data={}): 'name': mentorship_service.name, 'slug': mentorship_service.slug, 'status': mentorship_service.status, + 'video_provider': mentorship_service.video_provider, **data, } diff --git a/breathecode/mentorship/tests/urls_shortner/tests_meet_slug_service_slug.py b/breathecode/mentorship/tests/urls_shortner/tests_meet_slug_service_slug.py index c3584a97d..cbd36d274 100644 --- a/breathecode/mentorship/tests/urls_shortner/tests_meet_slug_service_slug.py +++ b/breathecode/mentorship/tests/urls_shortner/tests_meet_slug_service_slug.py @@ -92,10 +92,19 @@ def get_env(key, value=None): return get_env -def render_message(message, data={}): +def render_message(message, data={}, academy=None): request = None context = {'MESSAGE': message, 'BUTTON': None, 'BUTTON_TARGET': '_blank', 'LINK': None, **data} + if academy: + context['COMPANY_INFO_EMAIL'] = academy.feedback_email + context['COMPANY_LEGAL_NAME'] = academy.legal_name or academy.name + context['COMPANY_LOGO'] = academy.logo_url + context['COMPANY_NAME'] = academy.name + + if 'heading' not in data: + context['heading'] = academy.name + return loader.render_to_string('message.html', context, request) @@ -540,7 +549,10 @@ def test_with_mentor_profile(self): model = self.bc.database.create(user=1, token=1, mentor_profile=1, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=permission) @@ -587,7 +599,10 @@ def test_with_mentor_profile__bad_statuses(self): model = self.bc.database.create(user=1, token=1, mentor_profile=mentor_profile, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=permission) @@ -639,7 +654,10 @@ def test_with_mentor_profile__good_statuses__without_mentor_urls(self): model = self.bc.database.create(user=1, token=1, mentor_profile=mentor_profile, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=permission) @@ -706,7 +724,10 @@ def test_with_mentor_profile__good_statuses__with_mentor_urls__with_mentee(self) model = self.bc.database.create(user=1, token=1, mentor_profile=mentor_profile, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=permission) @@ -778,7 +799,10 @@ def test_with_mentor_profile__good_statuses__with_mentor_urls__with_mentee__not_ model = self.bc.database.create(user=1, token=1, mentor_profile=mentor_profile, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=permission) @@ -1177,7 +1201,10 @@ def test_with_mentor_profile__without_user_name(self): model = self.bc.database.create(mentor_profile=mentor_profile, mentorship_session=mentorship_session, user=user, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, academy=academy) model.mentorship_session.mentee = None @@ -1222,6 +1249,86 @@ def test_with_mentor_profile__without_user_name(self): self.bc.database.delete('auth.Permission') self.bc.database.delete('auth.User') + """ + 🔽🔽🔽 GET without MentorProfile, good statuses with mentor urls, MentorshipSession without mentee + passing session and mentee but mentee does not exist, user without name + """ + + @patch('breathecode.mentorship.actions.get_pending_sessions_or_create', + MagicMock(side_effect=Exception('Error inside get_pending_sessions_or_create'))) + @patch('breathecode.mentorship.actions.mentor_is_ready', MagicMock()) + @patch('os.getenv', MagicMock(side_effect=apply_get_env({ + 'DAILY_API_URL': URL, + 'DAILY_API_KEY': API_KEY, + }))) + @patch('requests.request', + apply_requests_request_mock([(201, f'{URL}/v1/rooms', { + 'name': ROOM_NAME, + 'url': ROOM_URL, + })])) + def test_error_inside_get_pending_sessions_or_create(self): + cases = [{ + 'status': x, + 'online_meeting_url': self.bc.fake.url(), + 'booking_url': self.bc.fake.url(), + } for x in ['ACTIVE', 'UNLISTED']] + permission = {'codename': 'join_mentorship'} + + id = 0 + for mentor_profile in cases: + id += 1 + + user = {'first_name': '', 'last_name': ''} + base = self.bc.database.create(user=user, token=1, group=1, permission=permission) + + mentorship_session = {'mentee_id': None} + academy = {'available_as_saas': False} + model = self.bc.database.create(mentor_profile=mentor_profile, + mentorship_session=mentorship_session, + user=user, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, + academy=academy) + + model.mentorship_session.mentee = None + model.mentorship_session.save() + + querystring = self.bc.format.to_querystring({ + 'token': base.token.key, + }) + url = reverse_lazy('mentorship_shortner:meet_slug_service_slug', + kwargs={ + 'mentor_slug': model.mentor_profile.slug, + 'service_slug': model.mentorship_service.slug + }) + f'?{querystring}' + response = self.client.get(url) + + content = self.bc.format.from_bytes(response.content) + expected = render_message('Error inside get_pending_sessions_or_create', academy=model.academy) + + # dump error in external files + if content != expected: + with open('content.html', 'w') as f: + f.write(content) + + with open('expected.html', 'w') as f: + f.write(expected) + + self.assertEqual(content, expected) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(self.bc.database.list_of('mentorship.MentorProfile'), [ + self.bc.format.to_dict(model.mentor_profile), + ]) + self.assertEqual(self.bc.database.list_of('payments.Consumable'), []) + self.assertEqual(self.bc.database.list_of('payments.ConsumptionSession'), []) + + # teardown + self.bc.database.delete('mentorship.MentorProfile') + self.bc.database.delete('auth.Permission') + self.bc.database.delete('auth.User') + # TODO: disabled until have a new feature flags manager # """ # 🔽🔽🔽 GET without MentorProfile, good statuses with mentor urls, MentorshipSession without mentee @@ -1261,7 +1368,7 @@ def test_with_mentor_profile__without_user_name(self): # model = self.bc.database.create(mentor_profile=mentor_profile, # mentorship_session=mentorship_session, # user=user, - # mentorship_service=1, + # mentorship_service={'language': 'en', 'video_provider': 'DAILY'}, # academy=academy) # model.mentorship_session.mentee = None @@ -1337,7 +1444,10 @@ def test_with_mentor_profile__academy_available_as_saas__flag_eq_true__mentee_wi model = self.bc.database.create(mentor_profile=mentor_profile, mentorship_session=mentorship_session, user=user, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, academy=academy) model.mentorship_session.mentee = None @@ -1415,7 +1525,10 @@ def test_with_mentor_profile__academy_available_as_saas__flag_eq_true__mentee_wi model = self.bc.database.create(mentor_profile=mentor_profile, mentorship_session=mentorship_session, user=user, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, academy=academy, plan={ 'is_renewable': False, @@ -1505,7 +1618,7 @@ def test_with_mentor_profile__academy_available_as_saas__flag_eq_true__mentee_wi how_many = random.randint(1, 100) consumable = {'how_many': how_many} delta = timedelta(seconds=random.randint(1, 1000)) - mentorship_service = {'max_duration': delta} + mentorship_service = {'max_duration': delta, 'language': 'en'} model = self.bc.database.create(mentor_profile=mentor_profile, mentorship_session=mentorship_session, user=user, @@ -1618,7 +1731,7 @@ def test_with_mentor_profile__academy_available_as_saas__flag_eq_true__bypass_me mentorship_session = {'mentee_id': None} academy = {'available_as_saas': True} delta = timedelta(seconds=random.randint(1, 1000)) - mentorship_service = {'max_duration': delta} + mentorship_service = {'max_duration': delta, 'language': 'en'} model = self.bc.database.create(mentor_profile=mentor_profile, mentorship_session=mentorship_session, user=user, @@ -1791,20 +1904,28 @@ def test_with_mentor_profile__ends_at_less_now__with_extend_true(self): mentorship_session_base = {'mentee_id': base.user.id, 'ends_at': ends_at} # session, token - cases = [({ - **mentorship_session_base, - 'allow_mentors_to_extend': True, - }, None), ({ - **mentorship_session_base, - 'allow_mentee_to_extend': True, - }, 1)] + cases = [ + ({ + **mentorship_session_base, + 'allow_mentee_to_extend': True, + 'allow_mentors_to_extend': False, + }, None), + ({ + **mentorship_session_base, + 'allow_mentee_to_extend': False, + 'allow_mentors_to_extend': True, + }, 1), + ] for mentorship_session, token in cases: model = self.bc.database.create(mentor_profile=mentor_profile, mentorship_session=mentorship_session, user=user, token=token, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=base.permission) @@ -1898,9 +2019,13 @@ def test_with_mentor_profile__ends_at_less_now__with_extend_true__extend_session # session, token cases = [({ 'allow_mentors_to_extend': True, - }, None), ({ + 'allow_mentee_to_extend': False, + 'language': 'en', + }, 1), ({ + 'allow_mentee_to_extend': False, 'allow_mentee_to_extend': True, - }, 1)] + 'language': 'en', + }, None)] for mentorship_service, token in cases: model = self.bc.database.create(mentor_profile=mentor_profile, @@ -2000,10 +2125,12 @@ def test_with_mentor_profile__ends_at_less_now__with_extend_true__session_can_no ({ 'allow_mentors_to_extend': False, 'allow_mentee_to_extend': False, + 'language': 'en', }, None), ({ 'allow_mentors_to_extend': False, 'allow_mentee_to_extend': False, + 'language': 'en', }, 1), ] @@ -2114,7 +2241,10 @@ def test_with_mentor_profile__ends_at_less_now__with_extend_true__redirect_to_se mentorship_session=mentorship_session, user=user, token=token, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=base.permission) @@ -2214,7 +2344,10 @@ def test_with_mentor_profile__redirect_to_session__no_saas(self): mentorship_session=mentorship_session, user=user, token=token, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=base.permission) @@ -2313,7 +2446,10 @@ def test_with_mentor_profile__redirect_to_session__saas(self): mentorship_session=mentorship_session, user=user, token=token, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=base.permission, academy=academy) @@ -2390,7 +2526,7 @@ def test_with_mentor_profile__redirect_to_session__saas__(self): consumable = {'how_many': how_many} delta = timedelta(seconds=random.randint(1, 1000)) - mentorship_service = {'max_duration': delta} + mentorship_service = {'max_duration': delta, 'language': 'en'} base = self.bc.database.create(user=user, token=1, group=1, @@ -2666,6 +2802,7 @@ def test__post__auth__no_saas__finantial_status_no_late(bc: Breathecode, client: mentorship_session = { **mentorship_session_base, 'allow_mentee_to_extend': True, + 'name': 'Session 1', } token = 1 @@ -2673,7 +2810,10 @@ def test__post__auth__no_saas__finantial_status_no_late(bc: Breathecode, client: mentorship_session=mentorship_session, user=user, token=token, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=base.permission) @@ -2797,7 +2937,10 @@ def test__post__auth__no_saas__finantial_status_late(bc: Breathecode, client: fx mentorship_session=mentorship_session, user=user, token=token, - mentorship_service=1, + mentorship_service={ + 'language': 'en', + 'video_provider': 'DAILY' + }, group=1, permission=base.permission) diff --git a/breathecode/mentorship/views.py b/breathecode/mentorship/views.py index d6a295d92..c51ca8f84 100644 --- a/breathecode/mentorship/views.py +++ b/breathecode/mentorship/views.py @@ -488,7 +488,10 @@ def __call__(self): # if the mentor is not the user, then we assume is the mentee mentee = self.token.user if is_token_of_mentee else None - sessions = self.get_pending_sessions_or_create(mentor, service, mentee) + try: + sessions = self.get_pending_sessions_or_create(mentor, service, mentee) + except Exception as e: + return render_message(self.request, str(e), status=400, academy=mentor.academy) if not is_token_of_mentee and sessions.count() > 0 and str(sessions.first().id) != self.query_params['session']: return self.render_pick_session(mentor, sessions) diff --git a/breathecode/services/__init__.py b/breathecode/services/__init__.py index 9dddcef2b..052345c3f 100644 --- a/breathecode/services/__init__.py +++ b/breathecode/services/__init__.py @@ -1,4 +1,5 @@ from .datetime_to_iso_format import datetime_to_iso_format # noqa: F401 from .eventbrite import CAMPAIGN, SOURCE, Eventbrite # noqa: F401 from .google_cloud import Datastore, Function, Storage # noqa: F401 +from .google_meet import * # noqa: F401 from .launch_darkly import * # noqa: F401 diff --git a/breathecode/services/google_meet/__init__.py b/breathecode/services/google_meet/__init__.py new file mode 100644 index 000000000..1919cdab5 --- /dev/null +++ b/breathecode/services/google_meet/__init__.py @@ -0,0 +1 @@ +from .google_meet import * # noqa: F401 diff --git a/breathecode/services/google_meet/google_meet.py b/breathecode/services/google_meet/google_meet.py index 2b0d1909f..9fe838196 100644 --- a/breathecode/services/google_meet/google_meet.py +++ b/breathecode/services/google_meet/google_meet.py @@ -1,7 +1,82 @@ -from typing import Optional +from typing import Optional, TypedDict, Unpack +import google.apps.meet_v2.services.conference_records_service.pagers as pagers from asgiref.sync import async_to_sync from google.apps import meet_v2 +from google.apps.meet_v2.types import Space +from google.protobuf.field_mask_pb2 import FieldMask +from google.protobuf.timestamp_pb2 import Timestamp + +__all__ = ['GoogleMeet'] + + +class CreateSpaceRequest(TypedDict): + space: Space + + +class EndActiveConferenceRequest(TypedDict): + name: str + + +class GetConferenceRecordRequest(TypedDict): + name: str + + +class GetParticipantRequest(TypedDict): + name: str + + +class GetParticipantSessionRequest(TypedDict): + name: str + + +class GetRecordingRequest(TypedDict): + name: str + + +class GetSpaceRequest(TypedDict): + name: str + + +class UpdateSpaceRequest(TypedDict): + space: Space + update_mask: FieldMask + + +class GetTranscriptRequest(TypedDict): + name: str + + +class ListConferenceRecordsRequest(TypedDict): + page_size: int + page_token: str + filter: str # in EBNF format, space.meeting_code, space.name, start_time and end_time + + +class ListRecordingsRequest(TypedDict): + parent: str + page_size: int + page_token: str + + +class ListParticipantSessionsRequest(TypedDict): + parent: str + page_size: int + page_token: str + filter: str # in EBNF format, start_time and end_time + + +class ListTranscriptsRequest(TypedDict): + parent: str + page_size: int + page_token: str + + +class ListParticipantsRequest(TypedDict): + parent: str + page_size: int + page_token: str + filter: str # in EBNF format, start_time and end_time class GoogleMeet: @@ -9,9 +84,12 @@ class GoogleMeet: _conference_records_service_client: Optional[meet_v2.ConferenceRecordsServiceAsyncClient] def __init__(self): + from breathecode.setup import resolve_gcloud_credentials + + resolve_gcloud_credentials() + self._spaces_service_client = None self._conference_records_service_client = None - pass async def spaces_service_client(self): if self._spaces_service_client is None: @@ -25,31 +103,28 @@ async def conference_records_service_client(self): return self._conference_records_service_client - async def acreate_meeting(self, **kwargs): + async def acreate_space(self, **kwargs: Unpack[CreateSpaceRequest]) -> meet_v2.Space: # Create a client client = await self.spaces_service_client() # Initialize request argument(s) - request = meet_v2.CreateSpaceRequest() + request = meet_v2.CreateSpaceRequest(**kwargs) # Make the request - response = await client.create_space(request=request) - - # Handle the response - print(response) + return await client.create_space(request=request) @async_to_sync - async def create_meeting(self): - return await self.acreate_meeting() + async def create_space(self, **kwargs: Unpack[CreateSpaceRequest]) -> meet_v2.Space: + return await self.acreate_space(**kwargs) - async def aget_meeting(self): + async def aget_space(self, **kwargs: Unpack[GetSpaceRequest]) -> meet_v2.Space: # Create a client client = await self.spaces_service_client() # Initialize request argument(s) - request = meet_v2.GetSpaceRequest() + request = meet_v2.GetSpaceRequest(**kwargs) # Make the request response = await client.get_space(request=request) @@ -58,15 +133,15 @@ async def aget_meeting(self): print(response) @async_to_sync - async def get_meeting(self): - return await self.aget_meeting() + async def get_space(self, **kwargs: Unpack[GetSpaceRequest]) -> meet_v2.Space: + return await self.aget_space(**kwargs) - async def aupdate_space(self): + async def aupdate_space(self, **kwargs: Unpack[UpdateSpaceRequest]) -> meet_v2.Space: # Create a client client = await self.spaces_service_client() # Initialize request argument(s) - request = meet_v2.UpdateSpaceRequest() + request = meet_v2.UpdateSpaceRequest(**kwargs) # Make the request response = await client.update_space(request=request) @@ -75,117 +150,90 @@ async def aupdate_space(self): print(response) @async_to_sync - async def update_space(self): - return await self.aupdate_space() + async def update_space(self, **kwargs: Unpack[UpdateSpaceRequest]) -> meet_v2.Space: + return await self.aupdate_space(**kwargs) - async def aend_meeting(self, name: str): + async def aend_active_conference(self, **kwargs: Unpack[EndActiveConferenceRequest]) -> None: # Create a client client = await self.spaces_service_client() # Initialize request argument(s) - request = meet_v2.EndActiveConferenceRequest(name=name) + request = meet_v2.EndActiveConferenceRequest(**kwargs) # Make the request - await client.end_active_conference(request=request) + return await client.end_active_conference(request=request) @async_to_sync - async def end_meeting(self, name: str): - return await self.aend_meeting(name) + async def end_active_conference(self, **kwargs: Unpack[EndActiveConferenceRequest]) -> None: + return await self.aend_active_conference(**kwargs) - async def alist_participants(self, parent: str): + async def alist_participants(self, **kwargs: Unpack[ListParticipantsRequest]) -> pagers.ListParticipantsAsyncPager: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.ListParticipantsRequest(parent=parent) + request = meet_v2.ListParticipantsRequest(**kwargs) # Make the request - page_result = client.list_participants(request=request) - - # Handle the response - async for response in page_result: - print(response) + return await client.list_participants(request=request) - @async_to_sync - async def list_participants(self, parent: str): - return await self.alist_participants(parent) - - async def aget_participant(self, name: str): + async def aget_participant(self, **kwargs: Unpack[GetParticipantRequest]) -> meet_v2.Participant: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.GetParticipantRequest(name=name) + request = meet_v2.GetParticipantRequest(**kwargs) # Make the request - response = await client.get_participant(request=request) - - # Handle the response - print(response) + return await client.get_participant(request=request) @async_to_sync - async def get_participant(self, name: str): - return await self.aget_participant(name) + async def get_participant(self, **kwargs: Unpack[GetParticipantRequest]) -> meet_v2.Participant: + return await self.aget_participant(**kwargs) - async def alist_participant_sessions(self, parent: str): + async def alist_participant_sessions( + self, **kwargs: Unpack[ListParticipantSessionsRequest]) -> pagers.ListParticipantSessionsAsyncPager: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.ListParticipantSessionsRequest(parent=parent) + request = meet_v2.ListParticipantSessionsRequest(**kwargs) # Make the request - page_result = client.list_participant_sessions(request=request) - - # Handle the response - async for response in page_result: - print(response) - - @async_to_sync - async def list_participant_sessions(self, parent: str): - return await self.alist_participant_sessions(parent) + return await client.list_participant_sessions(request=request) - async def aget_participant_session(self, name: str): + async def aget_participant_session(self, + **kwargs: Unpack[GetParticipantSessionRequest]) -> meet_v2.ParticipantSession: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.GetParticipantSessionRequest(name=name) + request = meet_v2.GetParticipantSessionRequest(**kwargs) # Make the request - response = await client.get_participant_session(request=request) - - # Handle the response - print(response) + return await client.get_participant_session(request=request) @async_to_sync - async def get_participant_session(self, name: str): - return await self.aget_participant_session(name) + async def get_participant_session(self, + **kwargs: Unpack[GetParticipantSessionRequest]) -> meet_v2.ParticipantSession: + return await self.aget_participant_session(**kwargs) - async def alist_recordings(self, parent: str): + async def alist_recordings(self, **kwargs: Unpack[ListRecordingsRequest]) -> pagers.ListRecordingsAsyncPager: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.ListRecordingsRequest(parent=parent) + request = meet_v2.ListRecordingsRequest(**kwargs) # Make the request - page_result = client.list_recordings(request=request) + return await client.list_recordings(request=request) - # Handle the response - async for response in page_result: - print(response) - - @async_to_sync - async def list_recordings(self, parent: str): - return await self.alist_recordings(parent) - - async def aget_recording(self, name: str): + async def aget_recording(self, **kwargs: Unpack[GetRecordingRequest]) -> meet_v2.Recording: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.GetRecordingRequest(name=name) + request = meet_v2.GetRecordingRequest(**kwargs) # Make the request response = await client.get_recording(request=request) @@ -194,33 +242,25 @@ async def aget_recording(self, name: str): print(response) @async_to_sync - async def get_recording(self, name: str): - return await self.aget_recording(name) + async def get_recording(self, **kwargs: Unpack[GetRecordingRequest]) -> meet_v2.Recording: + return await self.aget_recording(**kwargs) - async def alist_transcripts(self, parent: str): + async def alist_transcripts(self, **kwargs: Unpack[ListTranscriptsRequest]) -> pagers.ListTranscriptsAsyncPager: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.ListTranscriptsRequest(parent=parent) + request = meet_v2.ListTranscriptsRequest(**kwargs) # Make the request - page_result = client.list_transcripts(request=request) - - # Handle the response - async for response in page_result: - print(response) - - @async_to_sync - async def list_transcripts(self, parent: str): - return await self.alist_transcripts(parent) + return await client.list_transcripts(request=request) - async def aget_transcript(self, name: str): + async def aget_transcript(self, **kwargs: Unpack[GetTranscriptRequest]) -> meet_v2.Transcript: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.GetTranscriptRequest(name=name) + request = meet_v2.GetTranscriptRequest(**kwargs) # Make the request response = await client.get_transcript(request=request) @@ -229,40 +269,30 @@ async def aget_transcript(self, name: str): print(response) @async_to_sync - async def get_transcript(self, name: str): - return await self.aget_transcript(name) + async def get_transcript(self, **kwargs: Unpack[GetTranscriptRequest]) -> meet_v2.Transcript: + return await self.aget_transcript(**kwargs) - async def alist_conference_records(self): + async def alist_conference_records( + self, **kwargs: Unpack[ListConferenceRecordsRequest]) -> pagers.ListConferenceRecordsAsyncPager: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.ListConferenceRecordsRequest() + request = meet_v2.ListConferenceRecordsRequest(**kwargs) # Make the request - page_result = client.list_conference_records(request=request) + return await client.list_conference_records(request=request) - # Handle the response - async for response in page_result: - print(response) - - @async_to_sync - async def list_conference_records(self): - return await self.alist_conference_records() - - async def aget_conference_record(self, name: str): + async def aget_conference_record(self, **kwargs: Unpack[GetConferenceRecordRequest]) -> meet_v2.ConferenceRecord: # Create a client client = await self.conference_records_service_client() # Initialize request argument(s) - request = meet_v2.GetConferenceRecordRequest(name=name) + request = meet_v2.GetConferenceRecordRequest(**kwargs) # Make the request - response = await client.get_conference_record(request=request) - - # Handle the response - print(response) + return await client.get_conference_record(request=request) @async_to_sync - async def get_conference_record(self, name: str): - return await self.aget_conference_record(name) + async def get_conference_record(self, **kwargs: Unpack[GetConferenceRecordRequest]) -> meet_v2.ConferenceRecord: + return await self.aget_conference_record(**kwargs) diff --git a/breathecode/tests/mixins/datetime_mixin.py b/breathecode/tests/mixins/datetime_mixin.py index 0791259e6..9abc55adb 100644 --- a/breathecode/tests/mixins/datetime_mixin.py +++ b/breathecode/tests/mixins/datetime_mixin.py @@ -3,17 +3,11 @@ """ import re -from datetime import datetime, timedelta -from django.utils import timezone -from breathecode.utils.datetime_integer import DatetimeInteger - - -def get_utc(): - date = timezone.now() - return date.tzinfo +from datetime import UTC, datetime +from django.utils import timezone -UTC = get_utc() +from breathecode.utils.datetime_integer import DatetimeInteger __all__ = ['DatetimeMixin'] @@ -36,7 +30,7 @@ def datetime_now(self) -> datetime: """ return timezone.now() - def datetime_to_iso(self, date=datetime.utcnow()) -> str: + def datetime_to_iso(self, date=datetime.now(UTC)) -> str: """ Transform a datetime to ISO 8601 format. @@ -72,7 +66,7 @@ def iso_to_datetime(self, iso: str) -> datetime: date = datetime.fromisoformat(string) return timezone.make_aware(date) - def datetime_to_ical(self, date=datetime.utcnow(), utc=True) -> str: + def datetime_to_ical(self, date=datetime.now(UTC), utc=True) -> str: s = f'{date.year:04}{date.month:02}{date.day:02}T{date.hour:02}{date.minute:02}{date.second:02}' if utc: s += 'Z' diff --git a/breathecode/tests/mixins/generate_models_mixin/utils/create_models.py b/breathecode/tests/mixins/generate_models_mixin/utils/create_models.py index 2b286e4a0..e5b2c5d2a 100644 --- a/breathecode/tests/mixins/generate_models_mixin/utils/create_models.py +++ b/breathecode/tests/mixins/generate_models_mixin/utils/create_models.py @@ -1,9 +1,10 @@ import logging from typing import Any -from breathecode.tests.mixins.generate_models_mixin.exceptions import BadArgument from mixer.backend.django import mixer +from breathecode.tests.mixins.generate_models_mixin.exceptions import BadArgument + from .argument_parser import argument_parser __all__ = ['create_models'] @@ -44,7 +45,7 @@ def debug_mixer(attr, path, **kwargs): def create_models(attr, path, **kwargs): - # does not remove this line are use very often + # does not remove this line is used very often # debug_mixer(attr, path, **kwargs) result = [ diff --git a/capyc/django/pytest/fixtures/database.py b/capyc/django/pytest/fixtures/database.py index 7766346b0..24163884a 100644 --- a/capyc/django/pytest/fixtures/database.py +++ b/capyc/django/pytest/fixtures/database.py @@ -352,6 +352,12 @@ def create(cls, **models): pending = {} + keys = [*models.keys()] + + for key in keys: + if models[key] is None or models[key] == 0: + del models[key] + # get descriptors for model_alias, _value in models.items(): try: From edee4875106ab59e291d744726203d0b29418ff9 Mon Sep 17 00:00:00 2001 From: jefer94 Date: Mon, 17 Jun 2024 17:09:45 -0500 Subject: [PATCH 4/4] add migration --- ...ntorshipservice_video_provider_and_more.py | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 breathecode/mentorship/migrations/0029_mentorshipservice_video_provider_and_more.py diff --git a/breathecode/mentorship/migrations/0029_mentorshipservice_video_provider_and_more.py b/breathecode/mentorship/migrations/0029_mentorshipservice_video_provider_and_more.py new file mode 100644 index 000000000..790fae0e2 --- /dev/null +++ b/breathecode/mentorship/migrations/0029_mentorshipservice_video_provider_and_more.py @@ -0,0 +1,106 @@ +# Generated by Django 5.0.6 on 2024-06-17 22:09 + +import breathecode.utils.validators.language +import datetime +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('admissions', '0064_academy_legal_name'), + ('mentorship', '0028_mentorshipsession_questions_and_answers'), + ] + + operations = [ + migrations.AddField( + model_name='mentorshipservice', + name='video_provider', + field=models.CharField(blank=True, + choices=[('DAILY', 'Daily'), ('GOOGLE_MEET', 'Google Meet')], + default=None, + max_length=15), + ), + migrations.AlterField( + model_name='mentorshipservice', + name='allow_mentee_to_extend', + field=models.BooleanField(blank=True, + default=None, + help_text='If true, mentees will be able to extend mentorship session'), + ), + migrations.AlterField( + model_name='mentorshipservice', + name='allow_mentors_to_extend', + field=models.BooleanField(blank=True, + default=None, + help_text='If true, mentors will be able to extend mentorship session'), + ), + migrations.AlterField( + model_name='mentorshipservice', + name='duration', + field=models.DurationField(blank=True, + default=None, + help_text='Default duration for mentorship sessions of this service'), + ), + migrations.AlterField( + model_name='mentorshipservice', + name='language', + field=models.CharField(blank=True, + default=None, + help_text='ISO 639-1 language code + ISO 3166-1 alpha-2 country code, e.g. en-US', + max_length=5, + validators=[breathecode.utils.validators.language.validate_language_code]), + ), + migrations.AlterField( + model_name='mentorshipservice', + name='max_duration', + field=models.DurationField( + blank=True, + default=None, + help_text='Maximum allowed duration or extra time, make it 0 for unlimited meetings'), + ), + migrations.AlterField( + model_name='mentorshipservice', + name='missed_meeting_duration', + field=models.DurationField( + blank=True, + default=None, + help_text="Duration that will be paid when the mentee doesn't come to the session"), + ), + migrations.CreateModel( + name='AcademyMentorshipSettings', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('duration', + models.DurationField(default=datetime.timedelta(seconds=3600), + help_text='Default duration for mentorship sessions of this service')), + ('max_duration', + models.DurationField( + default=datetime.timedelta(seconds=7200), + help_text='Maximum allowed duration or extra time, make it 0 for unlimited meetings')), + ('missed_meeting_duration', + models.DurationField( + default=datetime.timedelta(seconds=600), + help_text="Duration that will be paid when the mentee doesn't come to the session")), + ('language', + models.CharField(default='en', + help_text='ISO 639-1 language code + ISO 3166-1 alpha-2 country code, e.g. en-US', + max_length=5, + validators=[breathecode.utils.validators.language.validate_language_code])), + ('allow_mentee_to_extend', + models.BooleanField(default=True, + help_text='If true, mentees will be able to extend mentorship session')), + ('allow_mentors_to_extend', + models.BooleanField(default=True, + help_text='If true, mentors will be able to extend mentorship session')), + ('video_provider', + models.CharField(choices=[('DAILY', 'Daily'), ('GOOGLE_MEET', 'Google Meet')], + default='GOOGLE_MEET', + max_length=15)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('academy', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='admissions.academy')), + ], + ), + ]