воскресенье, 4 июня 2023 г.

 Интеграция СЦОС с edX platform 

https://github.com/VladimirAndropov/scos-platform 


 Инсталляция 


Склонировать в
/edx/app/edxapp/
Установить
pip install -r requrements/base.txt

Пояснения


 код СЦОС находится здесь 
openedx/features/scos/ 
 Виджет экспорта в СЦОС
 cms/templates/export_scos.html
 cms/djangoapps/contentstore/views/export_scos.py
 В связи с тем, что требовались дополнительные атрибуты курса, были добавлены
 > competences = serializers.CharField(max_length=255) 
> > accreditated = serializers.CharField() 
> > assessment_description = serializers.CharField() 
> > duration = serializers.BooleanField()
 > > estimation_tools = serializers.CharField()
 > > hours = serializers.BooleanField() 
> > hours_per_week = serializers.BooleanField()
 > > proctoring_service = serializers.CharField() 
> > proctoring_type = serializers.CharField()
 > > requirements = serializers.CharField() 
> > business_version = serializers.BooleanField() 

 Поля дополнительных атрибутов заполняются в 
 Студия - Расширенные настройки 

Функция для передачи результатов в СЦОС

 ## update_subsection_grade_scos_for_user_v2 

 Асинхронный запуск функции передачи результатов в СЦОС

 lms/djangoapps/certificates/signals.py строка 146 

 Ручной запуск функции передачи результатов в СЦОС на странице прогресса 

 lms/templates/courseware/progress.html 

 Дополнительные поля справа на странице

 lms/templates/courseware/course_about.html 

 Параметры настроек доступа

 Данные поля должны быть заполнены в соответствии с вашими ключами доступа, выданными в СЦОС
 lms.envs.json cms.envs.json 
 "SSO_BASE_URL": "https://auth-test.online.edu.ru/realms/portfolio",
 "PLATFORM_ID": "39************8a6db466",
 "INSTITUTION_ID": "7************9ff5",
 "DOMAIN": "test.online.edu.ru", 
"API_URL": "https://test.online.edu.ru/api/",
 "API_USER": "**y",
 "API_USER_ID": "****y", 
"API_PASSWORD": "2***",
 "PORTFOLIO_API_URL": "https://portfolio.edu.ru/api/", 
"CLIENT_ID" : "***y",
 "CLIENT_SECRET_KEY" : "2*****",

 Файлы сертификата и ключ в папке openedx/features/scos/keys/bc007e88-0e4b-4a1a-975a-8aecca36542d.crt openedx/features/scos/keys/bc007e88-0e4b-4a1a-975a-8aecca36542d.key или пропишите их сами openedx/features/scos/conf.py строки 20-22 SSL_CERT = MODULE_DIR + '/keys/bc007e88-0e4b-4a1a-975a-8aecca36542d.crt' SSL_KEY = MODULE_DIR + '/keys/bc007e88-0e4b-4a1a-975a-8aecca36542d.key'

пятница, 13 января 2023 г.

Screenshots Insights.online.fa.ru

 

Данные о городе проживания из профиля

Пол из профиля на online.af.ru 



Регистрация по дням

Список курсов из online.fa.ru

Insights.online.fa.ru

пятница, 23 декабря 2022 г.

task: LastCountryOfUser

Географическая метка поьзователей 

remote-task --host insights --user vladimir --private-key /home/vladimir/yandex-fa.pem --remote-name analyticstack --skip-setup --wait  --local-scheduler   \

LastCountryOfUser   --interval 2022-01-01-2022-12-12 \

  --n-reduce-tasks 1 \

  --overwrite-n-days 365

четверг, 22 декабря 2022 г.

task: ModuleEngagementWorkflowTask

 remote-task --host insights --user vladimir --private-key /home/vladimir/yandex-fa.pem --local-scheduler --remote-name analyticstack --skip-setup --wait ModuleEngagementWorkflowTask --date $(date +%Y-%m-%d -d "2021-12-12") --indexing-tasks 5 --throttle 0.5 --n-reduce-tasks 1


--local-scheduler - указатель использовать локальный луиджи


hadoop task: ImportEnrollmentsIntoMysql

 remote-task --host insights --user vladimir --private-key /home/vladimir/yandex-fa.pem --remote-name analyticstack --skip-setup --wait ImportEnrollmentsIntoMysql --local-scheduler \

  --interval 2018-01-01-2018-12-12 \

  --n-reduce-tasks 1 \

  --overwrite-mysql \

  --overwrite-hive  --overwrite-n-days 365


host - имя машины которую анализируем

private-key - ключ доступа по ssh

четверг, 1 декабря 2022 г.

analytics.tasks

 analytics.tasks


    # common

    sqoop-import = edx.analytics.tasks.common.sqoop:SqoopImportFromMysql

    insert-into-table = edx.analytics.tasks.common.mysql_load:MysqlInsertTask

    bigquery-load = edx.analytics.tasks.common.bigquery_load:BigQueryLoadTask


    # insights

    answer-dist = edx.analytics.tasks.insights.answer_dist:AnswerDistributionPerCourse

    calendar = edx.analytics.tasks.insights.calendar_task:CalendarTableTask

    course_blocks = edx.analytics.tasks.insights.course_blocks:CourseBlocksApiDataTask

    course_list = edx.analytics.tasks.insights.course_list:CourseListApiDataTask

    database-import = edx.analytics.tasks.insights.database_imports:ImportAllDatabaseTablesTask

    engagement = edx.analytics.tasks.insights.module_engagement:ModuleEngagementDataTask

    enrollments = edx.analytics.tasks.insights.enrollments:ImportEnrollmentsIntoMysql

    location-per-course = edx.analytics.tasks.insights.location_per_course:LastCountryOfUser

    problem_response = edx.analytics.tasks.insights.problem_response:LatestProblemResponseDataTask

    tags-dist = edx.analytics.tasks.insights.tags_dist:TagsDistributionPerCourse

    user-activity = edx.analytics.tasks.insights.user_activity:InsertToMysqlCourseActivityTask

    video = edx.analytics.tasks.insights.video:InsertToMysqlAllVideoTask


    # data_api

    grade-dist = edx.analytics.tasks.data_api.studentmodule_dist:GradeDistFromSqoopToMySQLWorkflow

    student_engagement = edx.analytics.tasks.data_api.student_engagement:StudentEngagementTask


    # warehouse:

    event-type-dist = edx.analytics.tasks.warehouse.event_type_dist:PushToVerticaEventTypeDistributionTask

    load-course-catalog = edx.analytics.tasks.warehouse.load_internal_reporting_course_catalog:PullDiscoveryCoursesAPIData

    load-d-certificates = edx.analytics.tasks.warehouse.load_internal_reporting_certificates:LoadInternalReportingCertificatesToWarehouse

    load-d-country = edx.analytics.tasks.warehouse.load_internal_reporting_country:LoadInternalReportingCountryToWarehouse

    load-d-user = edx.analytics.tasks.warehouse.load_internal_reporting_user:LoadInternalReportingUserToWarehouse

    load-d-user-course = edx.analytics.tasks.warehouse.load_internal_reporting_user_course:LoadUserCourseSummary

    load-events = edx.analytics.tasks.warehouse.load_internal_reporting_events:TrackingEventRecordDataTask

    load-f-user-activity = edx.analytics.tasks.warehouse.load_internal_reporting_user_activity:LoadInternalReportingUserActivityToWarehouse

    load-internal-database = edx.analytics.tasks.warehouse.load_internal_reporting_database:ImportMysqlToVerticaTask

    load-internal-active-users = edx.analytics.tasks.warehouse.load_internal_reporting_active_users:LoadInternalReportingActiveUsersToWarehouse

    load-warehouse = edx.analytics.tasks.warehouse.load_warehouse:LoadWarehouseWorkflow

    load-warehouse-bigquery=edx.analytics.tasks.warehouse.load_warehouse_bigquery:LoadWarehouseBigQueryTask

    push_to_vertica_lms_courseware_link_clicked = edx.analytics.tasks.warehouse.lms_courseware_link_clicked:PushToVerticaLMSCoursewareLinkClickedTask

    run-vertica-sql-script = edx.analytics.tasks.warehouse.run_vertica_sql_script:RunVerticaSqlScriptTask

    run-vertica-sql-scripts = edx.analytics.tasks.warehouse.run_vertica_sql_scripts:RunVerticaSqlScriptTask

    test-vertica-sqoop = edx.analytics.tasks.common.vertica_export:VerticaSchemaToBigQueryTask


    # financial:

    cybersource = edx.analytics.tasks.warehouse.financial.cybersource:DailyPullFromCybersourceTask

    ed_services_report = edx.analytics.tasks.warehouse.financial.ed_services_financial_report:BuildEdServicesReportTask

    financial_reports  = edx.analytics.tasks.warehouse.financial.finance_reports:BuildFinancialReportsTask

    orders = edx.analytics.tasks.warehouse.financial.orders_import:OrderTableTask

    payment_reconcile = edx.analytics.tasks.warehouse.financial.reconcile:ReconcileOrdersAndTransactionsTask

    paypal = edx.analytics.tasks.warehouse.financial.paypal:PaypalTransactionsByDayTask


    # export:

    data_obfuscation   = edx.analytics.tasks.export.data_obfuscation:ObfuscatedCourseDumpTask

    dump-student-module = edx.analytics.tasks.export.database_exports:StudentModulePerCourseTask

    events_obfuscation = edx.analytics.tasks.export.events_obfuscation:ObfuscateCourseEventsTask

    export-events = edx.analytics.tasks.export.event_exports:EventExportTask

    export-events-by-course = edx.analytics.tasks.export.event_exports_by_course:EventExportByCourseTask

    export-student-module = edx.analytics.tasks.export.database_exports:StudentModulePerCourseAfterImportWorkflow

    obfuscation = edx.analytics.tasks.export.obfuscation:ObfuscatedCourseTask


    # monitor:

    all_events_report = edx.analytics.tasks.monitor.total_events_report:TotalEventsReportWorkflow

    enrollment_validation = edx.analytics.tasks.monitor.enrollment_validation:CourseEnrollmentValidationTask

    overall_events = edx.analytics.tasks.monitor.overall_events:TotalEventsDailyTask

    noop = edx.analytics.tasks.monitor.performance:ParseEventLogPerformanceTask


    # enterprise:

    enterprise_enrollments = edx.analytics.tasks.enterprise.enterprise_enrollments:ImportEnterpriseEnrollmentsIntoMysql


mapreduce.engine =

    hadoop = edx.analytics.tasks.common.mapreduce:MapReduceJobRunner

    local = luigi.contrib.hadoop:LocalJobRunner

    emu = edx.analytics.tasks.common.mapreduce:EmulatedMapReduceJobRunner


Staff Graded Assignment XBlock

 Дописан функционал создания zip файлов присланных студентами файлов в директории
course.name - course.run - block_id

Пример:
/edx/var/edxapp/media/fa/finadvice/edx_sga_zip/2022/9a0ceeb19/Ivanov.zip

Создание zip запускается так:

./manage.py lms --setting=aws sga_migrate_submissions
--course 'course-v1:fa+finadvice+2022_9_1'
--block 'block-v1:fa+finadvice+2022_9_1+type@edx_sga+block@0a1af775926a41b798b841209af15c75'


Код: кинуть в папку management 

sga_migrate_submissions.py



import json

from django.core.management.base import BaseCommand, CommandError # lint-amnesty, pylint: disable=import-error
from courseware.courses import get_course_by_id # lint-amnesty, pylint: disable=import-error
from courseware.models import StudentModule # lint-amnesty, pylint: disable=import-error
from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=import-error
from student.models import anonymous_id_for_user # lint-amnesty, pylint: disable=import-error
from submissions import api as submissions_api # lint-amnesty, pylint: disable=import-error
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=import-error
from io import BytesIO
import zipfile
import os
import shutil
from opaque_keys.edx.locator import BlockUsageLocator
from django.core.files.storage import default_storage
from django.core.files.base import ContentFile
from edx_sga.constants import BLOCK_SIZE, ITEM_TYPE
from edx_sga.tasks import (
get_zip_file_name,
get_zip_file_path,
get_zip_file_dir,
zip_student_submissions,
_get_student_submissions
)

class Command(BaseCommand):
"""
Migrates existing SGA submissions for a course from old SGA implementation
to newer version that uses the 'submissions' application.
sga_migrate_submissions --course 'course-v1:fa+finadvice+2022_1' --block 'block-v1:fa+finadvice+2022_1+type@edx_sga+block@0a1af775926a41b798b841209af15c75'
"""
# args = ["<course_id>","<block_id>"]
def add_arguments(self, parser):
parser.add_argument(
'-c',
'--course',
metavar='COURSE_ID',
dest='course',
default=False,
help='zip course'
)
parser.add_argument(
'-b',
'--block',
metavar='BLOCK_ID',
dest='block',
default=False,
help='zip block'
)

def handle(self, *args, **options):
"""
Migrates existing SGA submissions.
"""
course_id = options['course']
block_id = options['block']
# course_id = 'course-v1:fa+finadvice+2022_9_1'
course_key = CourseKey.from_string(course_id)
course = get_course_by_id(course_key)
# block_id = 'block-v1:fa+finadvice+2022_9_1+type@edx_sga+block@f7324e8b3f3f464e9d532359a0ceeb19'

locator_unicode = block_id
locator = BlockUsageLocator.from_string(locator_unicode)
directory = get_zip_file_dir(locator)
directory_abs = os.path.join('/edx/var/edxapp/media', directory)
direcory_with_run=os.path.join(directory_abs, locator.run)
directory_with_block_id=os.path.join(direcory_with_run, locator.block_id)
if not os.path.exists(direcory_with_run):
os.makedirs(direcory_with_run)

if not os.path.exists(directory_with_block_id):
os.makedirs(directory_with_block_id)

student_submissions = _get_student_submissions(block_id, course_id, locator)
zip_file_bytes = BytesIO()
with zipfile.ZipFile(zip_file_bytes, 'w') as zip_pointer:
for student_username, submission_file_path in student_submissions:
with default_storage.open(submission_file_path, 'rb') as destination_file:
filename_in_zip = '{}_{}'.format(student_username,os.path.basename(submission_file_path))
print filename_in_zip
zip_pointer.writestr(filename_in_zip, destination_file.read())
zip_file_bytes.seek(0)
filename_with_block_id=os.path.join(directory_with_block_id, filename_in_zip +'.zip' )
with open(filename_with_block_id, "wb") as zip_file_pointer:
zip_file_pointer.write(zip_file_bytes.getvalue())
print 'created '+filename_with_block_id


вторник, 29 ноября 2022 г.

Hadoop + hive + Spark

Добавляем файл для дальнейшего анализа  

 /edx/app/hadoop/hadoop/bin/hdfs dfs -put -f /edx/var/log/tracking/tracking.log hdfs://localhost:9000/data/tracking.log

Проверяем

hdfs dfs -ls /data

Запускаем расчёт через подключение к удаленному хосту по ключу

remote-task --host server3 --user vladimir --private-key /home/vladimir/yandex-fa.pem --remote-name analyticstack --skip-setup --wait \ 

AnswerDistributionWorkflow --local-scheduler \

--src hdfs://localhost:9000/data \

--dest  hdfs://localhost:9000/edx-analytics-pipeline \

--name hadoop  --output-root hdfs://localhost:9000/output/ \

--include 'tracking.log.gz'  \

--manifest hdfs://localhost:9000/data/manifest.txt  \

--base-input-format "org.edx.hadoop.input.ManifestTextInputFormat" \

 --lib-jar "hdfs://localhost:9000/edx-analytics-pipeline/packages/edx-analytics-hadoop-util.jar" --n-reduce-tasks 1 \

--marker hdfs://localhost:9000/edx-analytics-pipeline/marker/  \

--credentials "/edx/etc/edx-analytics-pipeline/output.json" 

 

пятница, 18 ноября 2022 г.

Верификация по фото

Как продлить действие верификации до 2025

SQL: 

Update edxapp.verify_student_softwaresecurephotoverification SET submitted_at='2024-12-12 12:12:12.000000', updated_at='2022-11-18 12:12:12.808484' WHERE edxapp.verify_student_softwaresecurephotoverification.submitted_at is not null;


Проверить настройки







 

воскресенье, 30 октября 2022 г.

Почта

 Настройки почты 

    "EMAIL_HOST_PASSWORD": "**********,

    "EMAIL_HOST_USER": "vvandropov@fa.ru",

"DEFAULT_FROM_EMAIL": "vvandropov@fa.ru",

    "EMAIL_BACKEND": "django_smtp_ssl.SSLEmailBackend",

    "EMAIL_HOST": "smtp.mail.ru",

    "EMAIL_PORT": 465,

    "EMAIL_USE_TLS": false,

    "EMAIL_USE_SSL": true,


Настройки  postfix



mydestination = edx3.ru-central1.internal, localhost
relayhost =[smtp.mail.ru]:465
alias_database = hash:/etc/aliases
alias_maps = hash:/etc/aliases
command_directory = /usr/sbin
data_directory = /var/lib/postfix
debug_peer_level = 2
debugger_command = 'PATH=/bin:/usr/bin:/usr/local/bin:/usr/X11R6/bin ddd $daemon_directory/$process_name $process_id & sleep 5'
html_directory = no
inet_interfaces = localhost
inet_protocols = ipv4
mail_owner = postfix
manpage_directory = /usr/share/man
mydomain = edx3.ru-central1.internal
myhostname = edx3.ru-central1.internal
mynetworks = 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
queue_directory = /var/spool/postfix
setgid_group = postdrop
smtp_tls_security_level = encrypt
smtp_use_tls = yes
unknown_local_recipient_reject_code = 550
sendmail_path = /usr/sbin/sendmail
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
mailq_path = /usr/bin/mailq
newaliases_path = /usr/bin/newaliases
smtp_generic_maps = hash:/etc/postfix/generic
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_wrappermode = yes
compatibility_level = 2


aliase 

root;  vvandropov@fa.ru

generic 

root  mycoolcamera@mail.r

sasl-password 

[smtp.mail.ru]:465 mycoolcamera@mail.ru:*********


commands after

postmap /etc/postfix/sasl_passwd

root@server3:/home/vladimir# postmap /etc/postfix/generic

root@server3:/home/vladimir# systemctl restart postfix 

root@server3:/home/vladimir# postfix reload

root@server3:/home/vladimir# systemctl restart postfix 

root@server3:/home/vladimir# echo "hllo world" |mail -s "tm"  mycoolcamera@mail.ru

root@server3:/home/vladimir# tail -111 /var/log/mail.log 

понедельник, 17 октября 2022 г.

 модуль Rate -

рейтинг курсов и комментарии студентов о курсе


Чтобы использовать функционал рейтинга и комментариев студентов нужно:

В расширенных настройках Studio добавить модуль Rate




В созданном курсе , желательно в конце курса, добавить блок Provide Feedback


В конце курса добавлен отдельный раздел под рейтинг курса


 В результате на следующий день (после ночного перерасчета) на странице курса добавятся отзывы студентов


 Как переименовать курс


Если ошиблись в названии курса, то идём в Расширенные настройки и меняем
Неизменяемое название курса
При этом ссылка на курс будет старая, но НАЗВАНИЕ будет новым 








воскресенье, 16 октября 2022 г.

Блок "отзывы и комментарии"


 На главной странице курсов, у которых в настройках подключен модуль RATE, отображаются комментарии, которые оставили студенты прошедшие курс (одноименные курсы).

Комментарии записываются в файл reviews.csv

в виде 

UX_UI_design 11/28/2021 11:19 3356 4 null
UX_UI_design 09/10/2021 13:26 14186 null null
UX_UI_design 05/05/2022 19:17 14921 null null
UX_UI_design 06/09/2022 12:41 22234 null null
UX_UI_design 02/24/2022 16:31 25974 null null
UX_UI_design 01/11/2022 16:04 26030 5 Мне понравился просмотренный мной курс, вся информация доступным языком, интересно смотреть и проходить задания!!!!
UX_UI_design 12/21/2021 17:23 26068 null null
UX_UI_design 01/13/2022 23:26 26118 5 Все интересно и доступно :) 

т.е. названию курса соответствует комментарий, при этом не имеет значения ни семестр, ни организация.
Поэтому, при публикации курса на другой семестр НЕ МЕНЯЙТЕ НАЗВАНИЕ КУРСА

Код выглядит следующим образом



<%
import csv
import os
import re
import numpy
import math
from datetime import datetime

# establish variables/global dicts
filestore = "/edx/app/edxapp/edx-platform/lms/templates/reviews.csv"
star_null = "<span class='fa fa-star-o' aria-hidden='true' style='color:rgb(210,210,210);'></span>"
star_empty = "<span class='fa fa-star-o' aria-hidden='true'></span>"
star_full = "<span class='fa fa-star' aria-hidden='true'></span>"
star_half_empty = "<span class='fa fa-star-half-empty' aria-hidden='true'></span>"
exceptions = ("\\n","<br/>"),("&lt;strong&gt;","<strong>"),("&lt;/strong&gt;","</strong>"),("&lt;u&gt;", "<u>"),("&lt;/u&gt;", "</u>"),("&lt;i&gt;", "<i>"),("&lt;/i&gt;", "</i>")
nope = ["onclick","onmouseover","onmouseout","onchange","onload","onkeydown","<script>", "<style>","window.","$(document)","edxapp","<%","DELETE FROM","SELECT","DROP TABLE","0 or 1=1","0 or 1 = 1"]
restrictions = map(str.lower, nope)
course_reviews = []
review_counts = {}
review_notnull = {}

# read reviews file & populate course-specific review lists
with open(filestore) as csv_file:
readCSV = csv.reader(csv_file, delimiter='\t')
try:
c = 1
for row in readCSV:
if not (row):
continue
elif row[0] != course.display_number_with_default:
continue
elif any(substring in row[4].lower() for substring in restrictions):
continue
elif row[0] == course.display_number_with_default:
if row[3] != "null" :
rating = int(row[3])
course_reviews.append(rating)
else:
rating = 0
if row[4] == "null":
continue
else:
review_notnull[row[1]] = [rating, row[4]]
else:
break
except:
pass


% if course_reviews:
<script src="https://cdn.bootcss.com/simplePagination.js/1.6/jquery.simplePagination.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.hoverintent/1.10.0/jquery.hoverIntent.js"></script>
<script>
$(document).ready(function() {
% if reviews > 1:
$(".aggregate-header").addClass("interactive");
% endif
$(".aggregate-header.interactive").hoverIntent(function() {
$("#pop-up").slideDown('fast');
$(".reviews *").css("filter","blur(0.25px)");
}, function() {
$("#pop-up").slideUp('fast');
$(".reviews *").css("filter","none");
});
});
</script>
<div class="course-reviews">
<h2 class="review-header">Рейтинг курса:</h2>
<div class="aggregate">
<div class="aggregate-header">
<h4>
% for i in range(0, int(whole)):
${star_full}
% endfor
% if dec:
${star_half_empty}
% endif
% if diff and whole < 5:
% for i in range(0, diff):
${star_empty}
% endfor
% endif
</h4>
<%
star_string = "звёзд" if review_aggregate > 1 else "звезда"
review_string = "оценкам" if reviews > 1 else "оценке"
%>
<h3 class="aggregate-text">${review_aggregate} ${star_string} в среднем по ${reviews} ${review_string}</h3>
</div>
% if len(review_counts.keys()) > 1:
<div id="pop-up" class="breakdown-popup" style="display:none;">
% for n in range(5, 0, -1):
% if n not in review_counts.keys():
<div class="breakdown-row">
<%
spacer = "&nbsp;"*11 if len(review_counts.keys()) == 1 else "&nbsp;"*9
%>
<div class="row-label"><h4>${n} star ${spacer}</h4></div>
<div class="bar-wrapper">
<p class="bar empty" style="width:100%"><span class="invisible">0</span></p>
</div>
</div>
<div class="break"></div>
% else:
<% rating_share = int((float(review_counts[n])/float(reviews))*100) %>
<div class="breakdown-row">
<div class="row-label"><h4>${n} star: <span style="color:green;">${rating_share}%</span></h4></div>
<div class="bar-wrapper">
<p class="bar" style="width:${rating_share}%"><span class="populated">${review_counts[n]}</span></p>
</div>
</div>
% endif
% endfor
</div>
% endif
</div>
% if review_notnull:
<% newline_filter(review_notnull) %>
<div class="reviews" id="reviews">
<h2>Оценки:</h2>
% for i in sorted(review_notnull, key=lambda x: datetime.strptime(x, '%m/%d/%Y %H:%M'), reverse=True):
<% rate = int(review_notnull[i][0]) %>
<div class="review">
% if rate == 0:
${star_null*5}
% elif rate < 5:
${star_full*rate}${star_empty*(5-rate)}
% else:
${star_full*rate}
% endif
<span class="review-date">${i[:10]}</span>
% if review_notnull[i][1]:
<h4 class="review-text">${ review_notnull[i][1] | x, basic_formats }</h4>
% endif
</div>
% endfor
<ul id="pages"></ul>
</div>
% endif
</div>
<script>
% else:
<div class="course-reviews">
<h2 class="review-header">Не оценивался</h2>
</div>
% endif


суббота, 1 октября 2022 г.

Celery и асинхронная передача в СЦОС

Интеграция СЦОС: авторизация

СЦОС online.edu.ru

Для работы нам потребуется зарегистрироваться на test.online.edu.ru и выяснить uid пользователя


uid

UID находится под именем пользователя, он будет использоваться во всех дальнейших тестах кода по дефолту
Вам лучше изменить его на свой, т.к. мой привязан к моим вузам

Для авторизации в СЦОС используются сертификаты. Вашей организации должны были выдать их на тестовую платформу.

Бэкенд авторизации class ScosOAuth2(OAuthAuth)
Для подключения через бэкенд к СЦОС воспользуйтесь следующей функцией

SSL_CERT = ‘/keys/путьк сертификату.crt
‘SSL_KEY = ‘/keys/путь к ключу.key’
JSON_HEADER = {‘Content-type’: ‘application/json’}

def get_api_requester():
  session = requests.Session()
  url = "https://test.online.edu.ru/api/v1/connection/check"
  session.get(url, headers=JSON_HEADER, cert=(SSL_CERT, SSL_KEY))
    return session

Для авторизации используется конструкция pipeline + third_party_auth
Чтобы проверить работоспособность данной конструкции авторизации перейдите в браузере по ссылке или сформируйте curl запрос на :
http://ВашаПлатформа/api/third_party_auth/v0/users/ВашЮзерНаплатформе

Вы увидите все third_auth бэкенды привязанные к своему пользователю, в соотвествующем СЦОС бэкенду в значении поля

"remote_id":

будет ваш uid — пользователя в СЦОС

Чтобы получить uid из БД

def get_user_scos_id(user_id): 
    cursor = connection.cursor()
    query = ("""SELECT uid FROM edxapp.social_auth_usersocialauth where user_id = '%s' ORDER BY ID DESC LIMIT 1""" % (user_id))
    cursor.execute (query) results = cursor.fetchall() for row in results: scos_id = row[0]
    return scos_id

Здесь мне пришлось использовать выборку и обрезать вывод одним значением, т к у меня было несколько uid для одного пользователя. Это связано с обновлениями СЦОС

Нам ещё потребуются выборки всех пользователей для экспорта оценок сразу по всем пользователем одним batch запросом.

def get_user_scos_ids(user_ids):
    # user_ids = ["930", "35"]
    scos_ids = []
    if bad_scos_ids() >= 1:
       return False
    cursor = connection.cursor()
    query = "SELECT uid FROM edxapp.social_auth_usersocialauth WHERE user_id IN ('%s') " % "','".join(user_ids) cursor.execute(query)
    results = cursor.fetchall() for key in reversed(results): b = key[0] scos_ids.append(b)
    return scos_ids

Здесь использована функция bad_scos_ids для проверки нескольких uid для одного пользователя. Я пришёл к выводу, что нужно пофиксить больше одного значения сразу в БД.


def bad_scos_ids():

cursor = connection.cursor()

query = "SELECT user_id, COUNT(user_id) FROM edxapp.social_auth_usersocialauth GROUP BY user_id HAVING COUNT(user_id) > 1"

cursor.execute(query)

results = cursor.fetchall()
return len(results)

Передача результатов в СЦОС

Все результаты передаются через Celery task Поэтому сведены в отдельный пост о Celery

Для передачи результатов по условиям СЦОС нам необходимо:

  • Зарегистрировать пользователя на курс на самом портале СЦОС
  • Содать в курсе результат, который будем передавать
  • Передать результаты в нужном формате

Здесь нет ничего необычного, просто мы будем использовать обновленную функцию из предыдущего поста
get_user_scos_id

def post_result(cid, uid, timestamp, rating, progress, checkpointName, checkpointId):
	# """Send user's atomic result to porfolio"""
    global api
    scos_cid = get_course_scos_id(cid)
    if scos_cid is None:
        return False
    scos_uid = get_user_scos_id(uid)
    if scos_uid is None:
        return False
    if check_scos_enrollment(cid, uid) == u'PARTICIPATION_NOT_FOUND':
        enroll_scos_user(cid, uid)
    
    # scos_cid = '1846d1bd-1f3c-4269-9e18-a61dbdc715cd'
    # scos_uid = 'fae8ffc0-c039-4526-a717-96f0f7cea172'
    # scos_uid = 'fbd0c270-bc21-46d6-b11b-3d5dea7734b4'
    tz = pytz.timezone('Europe/Moscow')
    # timestamp = timestamp.replace(tzinfo=pytz.utc).astimezone(tz)
    now = datetime.datetime.now(tz)
    now_str = now.strftime('%Y-%m-%dT%H:%M:%S%z')
    post_data = {
        'courseId': scos_cid,
        'sessionId': cid,
        'usiaId': scos_uid,
        'date': now_str,
        'rating': rating,
        'progress': progress,
        #'proctored': None,
        'checkpointName': checkpointName,
        'checkpointId': checkpointId
        }
    
    if api is None:
        api = get_api_requester()
        
    url = 'https://test.online.edu.ru/api/v1/course/results/add'
    resp = api.post(
        url
        ,data = json.dumps(post_data)
        ,headers = JSON_HEADER
        )
    
    logit('Result posted'
          + '  scos_cid: ' + scos_cid + '  scos_uid: ' + scos_uid
          + '  status: ' + str(resp.status_code)
          + '  response: ' + resp.text)
    
    try:
        resp.raise_for_status()
    except requests.exceptions.HTTPError:
        return False
    
    return True

Для передачи прогресса обучения используется передача массива значений, запрос должен быть обёрнут в []

def post_result_mark(cid, uid, mark):
	# """Send user's atomic result to porfolio"""
    global api
    scos_cid = get_course_scos_id(cid)
    scos_uid = get_user_scos_id(uid)  

    # scos_uid = 'fae8ffc0-c039-4526-a717-96f0f7cea172'
    # scos_cid = '1846d1bd-1f3c-4269-9e18-a61dbdc715cd'
    # cid = 'course-v1:fa+digitalmarket+2019_leto'
    # mark = '3'

    if scos_cid is None:
        return False

    if scos_uid is None:
        return False
    # if not enrollment_exists(scos_cid, scos_uid):
    #     return False
    
    post_data = [{
        'course_id': scos_cid,
        'session_id': cid,
        'user_id': scos_uid,
        'mark': mark
        }]
    
    if api is None:
        api = get_api_requester()
        
    url = 'https://test.online.edu.ru/api/v2/courses/participation/mark'
    resp = api.post(
        url
        ,data = json.dumps(post_data)
        ,headers = JSON_HEADER
        )
    
    
    try:
        resp.raise_for_status()
    except requests.exceptions.HTTPError:
        return False
    
    return True

Получить результаты оценок можно сразу по нескольким пользователям. Помните мы писали функцию в предыдущем посте? Здесь она нам и пригодится

def get_mark_batch(user_ids, cid):
    # user_ids = ["930", "35"]
    # scos_uid1 = 'fae8ffc0-c039-4526-a717-96f0f7cea172'
    # scos_uid2 = 'fbd0c270-bc21-46d6-b11b-3d5dea7734b4'
    # scos_uids = [scos_uid1, scos_uid2]
    # scos_cid = '1846d1bd-1f3c-4269-9e18-a61dbdc715cd'
    # cid = 'course-v1:fa+digitalmarket+2019_leto'
    global api
    scos_cid = get_course_scos_id(cid)
    scos_uids = get_user_scos_ids(user_ids)
    post_data = {
        'course_id': scos_cid,
        'session_id': cid,
        'user_ids': scos_uids
        }
    if api is None:
        api = get_api_requester()
    url = 'https://test.online.edu.ru/api/v2/courses/participation/mark/list'
    resp = api.post(
        url, data=json.dumps(post_data), headers=JSON_HEADER
        )
    return resp

Тоже касается и передачи результатов списком. Мы используем функцию get_scos_user_ids
Обратите внимание, что массивы значений должны передаваться в list

def post_mark_batch(user_ids, cid):
    user_ids = ["930", "35"]
    scos_uid1 = 'fae8ffc0-c039-4526-a717-96f0f7cea172'
    scos_uid2 = 'fbd0c270-bc21-46d6-b11b-3d5dea7734b4'
    scos_uids = [scos_uid1, scos_uid2]
    scos_cid = '1846d1bd-1f3c-4269-9e18-a61dbdc715cd'
    cid = 'course-v1:fa+digitalmarket+2019_leto'
    global api
    scos_cid = get_course_scos_id(cid)
    scos_uids = get_user_scos_ids(user_ids)

    post_data = {"list":[
        {
        "courseId": scos_cid,
        "sessionId": cid,
        "usiaId": scos_uid1,
        "progress": 35
        },
    ]}
    if api is None:
        api = get_api_requester()
    url = 'https://test.online.edu.ru/api/v1/course/results/progress/add/batch'
    resp = api.post(url, data=json.dumps(post_data), headers=JSON_HEADER)
    return resp

При успешной передаче Вы получите результаты на test.online.edu.ru

Передача курсов в СЦОС

Передача данных асинхронна и рассматривается в постах о CELERY

Перво-наперво, чтобы что-то передать, нужно сначала это создать.

Нам нужно создать файл шаблона курса c нужными пустыми полями, который будет в дальнейшем заполняться данными из Студии edX.
Пусть это будет default.json
Мы будем копировать его для соответствующего курса функцией

def create_course_from_default(course_id):
    filename = course_id.replace(COURSES_ID_PREFIX, '')
    shutil.copy(ENV('COURSES_DIR')+'/default.json', ENV('COURSES_DIR')+'/'+ filename + '.json')
    return None

Содержимое шалона такое:

{
    "partnerId": "3928301200ea45e899d2e3a78a6db466",
    "package": {
        "items": [
            {
                "id": "",
                "title": "",
                "started_at": "",
                "enrollment_finished_at": "",
                "finished_at": "",
                "image": "",
                "description": " ",
                "competences": "",
                "requirements": [],
                "content": "",
                "external_url": "",
                "direction": [
                    "10.03.01",
                    "27.00.00",
                    "38.00.00"
                ],
                "institution": "77e20215900e4ed1b5a424099fa19ff5",
                "duration": {
                    "code": "week",
                    "value": 8
                },
                "lectures": 10,
                "language": "ru",
                "cert": "true",
                "visitors": null,
                "teachers": [
                    {
                        "bio": ",
                        "display_name": "",
                        "name": "",
                        "title": "",
                        "image": "",
                        "organization": "",
                        "description": " Департамента менеджмента</p>     <p>Финансового университета при Правительстве Российской Федерации</p>"
                    }
                ],
                "transfers": [],
                "results": "",
                "accreditated": "",
                "hours": null,
                "hours_per_week": null,
                "business_version": "1",
                "promo_url": "",
                "promo_lang": "",
                "subtitles_lang": "",
                "estimation_tools": "",
                "proctoring_service": "",
                "sessionid": "1",
                "credits": 3,
                "assessment_description": ""
            }
        ]
    }
}

Поле partner_id и institution заполнено, и соотвествуют значениям полученым от правообладателя. У Вас будут свои значения, постарайтесь не использовать мои, Вы внесёте путаницу в работу поддержки СЦОС.

Фунцию create_course_from_default можно повесить на видном месте в Студии, на Ваше усмотрение.
Т.к. позже рассмотрим выполнение данной функции в отдельном посте о Celery

Изначально, функция активировалась при создании курса и указании scos_uid

Далее необходимо заполнить созданный данной функцией файл из Студии в настройках
Заполнение файла происходит при обновлении курса

Для экспорта содержимого файла в СЦОС создадим функцию export_scos

import logging

from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from django.utils.translation import ugettext as _
from django.views.decorators.csrf import ensure_csrf_cookie
from opaque_keys.edx.keys import CourseKey

from edxmako.shortcuts import render_to_response
from student.auth import has_course_author_access



import sys
import os
import io
import json
import exceptions

import pdb

from openedx.features.scos.conf import ENV, set_environment

from openedx.features.scos.roo import get_course_moderation_status, post_course_data, change_course_status, register_course

from openedx.features.scos.courses import get_course_scos_id, add_scos_course_to_list, get_course_scos_data,upate_course_scos_data, save_scos_courses_to_file, create_course_from_default



import copy
import datetime
import json
import unittest

import ddt
import mock
from django.conf import settings
from django.test.utils import override_settings
from pytz import UTC
from milestones.tests.utils import MilestonesTestCaseMixin
from mock import Mock, patch

from contentstore.utils import reverse_course_url, reverse_usage_url
from milestones.models import MilestoneRelationshipType
from models.settings.course_grading import CourseGradingModel, GRADING_POLICY_CHANGED_EVENT_TYPE, hash_grading_policy
from models.settings.course_metadata import CourseMetadata
from models.settings.encoder import CourseSettingsEncoder
from openedx.core.djangoapps.models.course_details import CourseDetails
from student.roles import CourseInstructorRole, CourseStaffRole

from util import milestones_helpers
from xblock_django.models import XBlockStudioConfigurationFlag
from xmodule.fields import Date
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.tabs import InvalidTabsException



log = logging.getLogger(__name__)


@ensure_csrf_cookie
@login_required
def export_scos(request, course_key_string):
    course_key = CourseKey.from_string(course_key_string)
#     course_key_string = 'course-v1:fa+digitalmarket+2019_leto'
#     course_key
# CourseLocator(u'fa', u'digitalmarket', u'2019_leto', None, None)
    status = 'None'
    msg = ""
    set_environment('TESTPLT')
    assert 'testplt' in ENV('COURSES_DIR')    
    
    if not has_course_author_access(request.user, course_key):
        raise PermissionDenied()
      
    if get_course_scos_id(course_key_string) is None:
        status = 'is_new_course'               
    else:
        status = get_course_moderation_status(course_key_string)
              
    course_module = modulestore().get_course(course_key)
    title = course_module.display_name
                  
    
    failed = False
    details = CourseDetails.fetch(course_key)
    log.debug('export_scos course_module=%s', course_module)
    # status = get_course_moderation_status(course_key_string) 
    # ok, failed, in_progress.
    jsondetails = json.dumps(details, cls=CourseSettingsEncoder)
    #jsondetails = json.dumps(details, default=lambda o: '<not serializable>')
    jsondetails = json.loads(jsondetails)    

    if 'action' in request.GET:
        if request.GET['action'] == 'push':
            try:
                data = get_course_scos_data(course_key_string, True)
                if jsondetails['certificate_available_date']:
                    cert = "true"
                    
                teachers_json = jsondetails['instructor_info']
                for entry in teachers_json['instructors']:
                    entry['display_name']= entry['name']
                    entry['description']= entry['title']
                    entry['image']= "https://online.fa.ru"+entry['image']

                upate_course_scos_data(course_key_string, {
                "started_at": jsondetails['start_date'],
                "enrollment_finished_at": jsondetails['enrollment_end'],
                "finished_at": jsondetails['end_date'],
                "image": "https://online.fa.ru"+jsondetails['course_image_asset_path'],
                "description": jsondetails['short_description'],
                "external_url": "https://online.fa.ru/courses/"+course_key_string+"/about",
                "content": jsondetails['overview'],                             
                "business_version": str(int(data['package']['items'][0]['business_version'])+1) ,
                "promo_url": "https://youtu.be/"+jsondetails['intro_video'],
                # "subtitles_lang": jsondetails['subtitle'],
                # "id": jsondetails['duration'],
                "direction": jsondetails['learning_info'],
                "competences":jsondetails['description'],
                "results": jsondetails['subtitle'],
                "language": jsondetails['language'],
                "requirements": jsondetails['pre_requisite_courses'],
                "cert": cert,
                'teachers': jsondetails['instructor_info']['instructors']                    
                                             })
                post_course_data(course_key_string)

                msg = _('Course successfully exported to scos repository')
            except Exception as e:
                failed = True
                upate_course_scos_data(course_key_string, {
                    "business_version": str(int(data['package']['items'][0]['business_version'])-1)})
                msg = 'Failed  '+course_key_string + ' with error: '+str(e)
        elif request.GET['action'] == 'active':
            try:
                status = change_course_status(course_key_string, 'active')
                assert status == 'ACTIVE'
            except Exception as e:
                failed = True
                msg = 'Failed  '+status + ' with error: '+str(e)
        elif request.GET['action'] == 'archive':
            try:
                status = change_course_status(course_key_string, 'archive')
                assert status == 'ARCHIVE'  
            except Exception as e:
                failed = True
                msg = 'Failed  '+status + ' with error: '+str(e)
        elif request.GET['action'] == 'new':
            try:
                create_course_from_default(course_key_string)
                # if jsondetails['certificate_available_date']:
                #     cert = "true"
                    
                # teachers_json = jsondetails['instructor_info']
                # for entry in teachers_json['instructors']:
                #     entry['display_name']= entry['name']
                #     entry['description']= entry['title']
                #     entry['image']= "https://online.fa.ru"+entry['image']
                    
                # upate_course_scos_data(course_key_string, {
                # "started_at": jsondetails['start_date'],
                # "enrollment_finished_at": jsondetails['enrollment_end'],
                # "finished_at": jsondetails['end_date'],
                # "image": "https://online.fa.ru"+jsondetails['course_image_asset_path'],
                # "description": jsondetails['short_description'],
                # "external_url": "https://online.fa.ru/courses/"+course_key_string+"/about",
                # "content": jsondetails['overview'],                             
                # "business_version": 1,
                # "promo_url": "https://youtu.be/"+jsondetails['intro_video'],
                # "subtitles_lang": jsondetails['subtitle'],
                # "id": jsondetails['duration'],
                # "direction": jsondetails['learning_info'],
                # "competences":jsondetails['description'],
                # "results": jsondetails['subtitle'],
                # "language": jsondetails['language'],
                # "requirements": jsondetails['pre_requisite_courses'],
                # "cert": cert,
                # # "title": title,
                # 'teachers': jsondetails['instructor_info']['instructors']                    
                #                              })
                add_scos_course_to_list(course_key_string, title, jsondetails['duration'])
                msg = _('New Course successfully created in course catalog /edx/var/scos/courses/')
                
            except Exception as e:
                failed = True
                msg = 'Failed  with error: '+str(e)
    return render_to_response('export_scos.html', {
        'context_course': course_module,
        'msg': msg,
        'failed': failed,
        'status': status,
    })

Функция рендерит export_scos.html содержащий кнопки для активации выполнения и результат выполнения.

В папке templates создайте файл export_scos.html следующего содержания:

<%page expression_filter="h"/>
<%inherit file="base.html" />
<%namespace name='static' file='static_content.html'/>

<%!
  from django.urls import reverse
  from django.utils.translation import ugettext as _
%>
<%block name="title">${_("Export Course to SCOS")}</%block>
<%block name="bodyclass">is-signedin course tools view-export-git</%block>

<%block name="content">

<div class="wrapper-mast wrapper">
  <header class="mast has-subtitle">
    <h1 class="page-header">
      <small class="subtitle">${_("Tools")}</small>
      <span class="sr">&gt; </span>${_("Export to SCOS")}
    </h1>
  </header>
</div>

<div class="wrapper-content wrapper">
  <section class="content">
    <article class="content-primary" role="main">

      <div class="introduction">
        <h2 class="title">${_("About Export to SCOS")}</h2>
        <div class="copy">
          <p>${_("Use this to export your course to its scos platform.")}</p>
          <p>${_("This will then trigger an automatic update of the main LMS site and update the contents of your course visible there to students if automatic  imports are configured.")}</p>
        </div>
      </div>

      <div class="export-git-controls">
        <h2 class="title">${_("Export Course to SCOS:")}</h2>

        % if status =='is_new_course':
        <ul class="list-actions">
          <li class="item-action">
            <a class="action action-export-git action-primary" href="${reverse('export_scos', kwargs=dict(course_key_string=unicode(context_course.id)))}?action=new">
              <span class="icon fa fa-warning" aria-hidden="true"></span>
              <span class="copy">${_("Add New course")}</span>
            </a>
          </li>
          Please do not forget add SCOS_general_uid <a href="../settings/details/${context_course.id}?#field-course-duration">there</a>
        </ul>
        % endif
        % if status !='is_new_course':
        <ul class="list-actions">
          <li class="item-action">
            <a class="action action-export-git action-primary" href="${reverse('export_scos', kwargs=dict(course_key_string=unicode(context_course.id)))}?action=push">
              <span class="icon fa fa-arrow-circle-o-down" aria-hidden="true"></span>
              <span class="copy">${_("Update in SCOS")}</span>
            </a>
          </li>
        </ul>
        % endif
        % if status =='in_progress':
        <ul class="list-actions">
          <li class="item-action">
            <a class="action action-export-git action-primary" href="${reverse('export_scos', kwargs=dict(course_key_string=unicode(context_course.id)))}?action=active">
              <span class="icon fa fa-cog" aria-hidden="true"></span>
              <span class="copy">${_("Activate this course in SCOS")}</span>
            </a>
          </li>
        </ul>
        % endif
        % if status =='ok':
        <ul class="list-actions">
          <li class="item-action">
            <a class="action action-export-git action-primary" href="${reverse('export_scos', kwargs=dict(course_key_string=unicode(context_course.id)))}?action=archive">
              <span class="icon fa fa-cog" aria-hidden="true"></span>
              <span class="copy">${_("Archive this course in SCOS")}</span>
            </a>
          </li>
        </ul>
        % endif
      </div>
      <div class="messages">
        % if msg:
          % if failed:
          <h3 class="error-text">${_('Export Failed')}:</h3>
          % else:
          <h3>${_('Export Succeeded')}:</h3>
          % endif
        <pre>${msg}</pre>
        % endif

      </div>
    </article>
    <aside class="content-supplementary" role="complementary">
      <dl class="export-git-info-block">
        <dt>${_("Your course:")}</dt>
        <dd class="course_text">${context_course.id}</dd>
        <dt>${_("SCOS url:")}</dt>
        <!-- <dd class="giturl_text">${context_course.giturl}</dd> -->
        <dd class="giturl_text">test.online.edu.ru</dd>
        <dt>${_("Status of this course:")}</dt>
        <dd class="course_text">${status}</dd>
      </dl>
    </aside>
  </section>
</div>
</%block>

Видимость кнопок зависит от статуса курса на платформе СЦОС соотвестсвенно (архивный/ активный).
В следующем видео то, что в итоге должно получиться:

Celery и асинхронная передача в СЦОС

В предыдущих статьях были написаны функции, которые требуют асинхронной передачи на портал СЦОС. В данном посте мы реализуем мехнизм передачи результатов с помощью CELERY

Прежде всего давайте протестируем передачу данных с помощью кнопок (на всякий случай)


От нас требуется передавать результаты испытаний по получении более высого результата за пройденный пользователем тест.
Давайте создадим задание:

@task(
    bind=True,
    base=LoggedPersistOnFailureTask,
    time_limit=1800,
    max_retries=2,
    default_retry_delay=9000,
    routing_key=settings.RECALCULATE_GRADES_ROUTING_KEY
)
def recalculate_subsection_grade_v3_scos(self, **kwargs):

    try:
        course_key = CourseLocator.from_string(kwargs['course_id'])

        set_custom_metrics_for_course_key(course_key)

        set_event_transaction_id(kwargs.get('event_transaction_id'))
        set_event_transaction_type(kwargs.get('event_transaction_type'))

        _update_subsection_grades_scos(
            course_key,
            kwargs['user_id'],
        )
    except Exception as exc:
        if not isinstance(exc, KNOWN_RETRY_ERRORS):
            log.info("tnl-6244 grades unexpected failure: {}. task id: {}. kwargs={}".format(
                repr(exc),
                self.request.id,
                kwargs,
            ))
        raise self.retry(kwargs=kwargs, exc=exc)

Здесь функция _update_subsection_grades_scos непосредственно передаёт результаты на портал СЦОС:

def _update_subsection_grades_scos(course_key, user_id):

    user = User.objects.get(id=user_id)
    # course_key = CourseKey.from_string(course_key_str)
    course_grade = CourseGradeFactory().read(user, course_key=course_key)
    courseware_summary = course_grade.chapter_grades.values()
    if courseware_summary:
        for chapter in courseware_summary:
            if not chapter['display_name'] == "hidden":
                for section in chapter['sections']:
                    earned = section.all_total.earned
                    total = section.all_total.possible
                    if total > 0 or earned > 0 and section.percent_graded  > 0:
                        rating = "{0:.2f}".format(100*section.percent_graded)                     
                        checkpointId = section.url_name
                        checkpointName = section.display_name
                        progress = 100*course_grade.summary['percent']
                        timestamp = datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%S%z')
                        msg = post_result(unicode(course_key), user_id, timestamp, rating, progress, checkpointName, checkpointId)
                        log.info(msg)

Теперь мы можем выполнять данное задание через функцию
recalculate_subsection_grade_v3_scos.apply_async(kwargs=task_args)

В данном случае мы передаём лишь два аргумента kwargs[‘user_id’], kwargs[‘course_id’] потому разумно исходить из тех сигналов, которые передают эти параметры. Рассмотрм, наример, эти:

from .signals import (
    PROBLEM_RAW_SCORE_CHANGED,
    PROBLEM_WEIGHTED_SCORE_CHANGED,
    SCORE_PUBLISHED,
    SUBSECTION_SCORE_CHANGED,
    SUBSECTION_OVERRIDE_CHANGED,

например: SUBSECTION_OVERRIDE_CHANGED

SUBSECTION_OVERRIDE_CHANGED = Signal(
    providing_args=[
        'user_id',  # Integer User ID
        'course_id',  # Unicode string representing the course
        'usage_id',  # Unicode string indicating the courseware instance
        'only_if_higher',   # Boolean indicating whether updates should be
                            # made only if the new score is higher than previous.
        'modified',  # A datetime indicating when the database representation of
                     # this subsection override score was saved.
        'score_deleted',  # Boolean indicating whether the override score was
                          # deleted in this event.
        'score_db_table',  # The database table that houses the subsection override
                           # score that was created.
    ]
)

И как вариант, создадим handler

@receiver(SUBSECTION_OVERRIDE_CHANGED)
def enqueue_subsection_update_scos(sender, **kwargs):  # pylint: disable=unused-argument
    recalculate_subsection_grade_v3_scos.apply_async(
        kwargs=dict(
            user_id=kwargs['user_id'],        
            course_id=kwargs['course_id'],
        ),
        countdown=RECALCULATE_GRADE_DELAY_SECONDS,
    )

Теперь мы отправляем результаты в СЦОС асинхронно по событию SUBSECTION_OVERRIDE_CHANGED, как того требовали рекомендации

Естественно, данный способ совсем не жалеет трафик, но если Вы дочитали до этого момента, то вполне можете придумать свой механизм используя множественную передачу, рассмотренную в предыдущих постах. Успехов!