From b20aa69064d14a498887a09cf97ff1e7fb0eb93b Mon Sep 17 00:00:00 2001 From: edX requirements bot Date: Tue, 5 Sep 2023 06:48:17 -0400 Subject: [PATCH] fix: setup.py update using script --- MANIFEST.in | 3 +- build/lib/staff_graded/__init__.py | 5 + build/lib/staff_graded/staff_graded.py | 301 ++++++++++++++++++ build/lib/staff_graded/static/README.txt | 19 ++ .../staff_graded/static/css/staff_graded.css | 14 + .../static/html/staff_graded.html | 65 ++++ .../static/js/src/staff_graded.js | 109 +++++++ setup.py | 92 +++++- 8 files changed, 597 insertions(+), 11 deletions(-) create mode 100644 build/lib/staff_graded/__init__.py create mode 100644 build/lib/staff_graded/staff_graded.py create mode 100644 build/lib/staff_graded/static/README.txt create mode 100644 build/lib/staff_graded/static/css/staff_graded.css create mode 100644 build/lib/staff_graded/static/html/staff_graded.html create mode 100644 build/lib/staff_graded/static/js/src/staff_graded.js diff --git a/MANIFEST.in b/MANIFEST.in index 40ecc6c..c357a1e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ -include requirements/base.in \ No newline at end of file +include requirements/base.in +include requirements/constraints.txt diff --git a/build/lib/staff_graded/__init__.py b/build/lib/staff_graded/__init__.py new file mode 100644 index 0000000..969822a --- /dev/null +++ b/build/lib/staff_graded/__init__.py @@ -0,0 +1,5 @@ +""" Loading Xblock class""" + +from .staff_graded import StaffGradedXBlock + +__version__ = '2.1.1' diff --git a/build/lib/staff_graded/staff_graded.py b/build/lib/staff_graded/staff_graded.py new file mode 100644 index 0000000..5c61f59 --- /dev/null +++ b/build/lib/staff_graded/staff_graded.py @@ -0,0 +1,301 @@ +""" +XBlock for Staff Graded Points +""" + + +import io +import json +import logging + +import markdown +import pkg_resources +from web_fragments.fragment import Fragment +from webob import Response +from xblock.core import XBlock +from xblock.fields import Float, Scope, String +from xblock.runtime import NoSuchServiceError +from xblock.scorable import ScorableXBlockMixin, Score +from xblockutils.resources import ResourceLoader +from xblockutils.studio_editable import StudioEditableXBlockMixin + +try: + from openedx.core.djangoapps.course_groups.cohorts import \ + get_course_cohorts +except ImportError: + get_course_cohorts = lambda course_key: [] # pylint: disable=unnecessary-lambda-assignment + +try: + from common.djangoapps.course_modes.models import CourseMode + modes_for_course = CourseMode.modes_for_course +except ImportError: + modes_for_course = lambda course_key: [('audit', 'Audit Track'), ('masters', "Master's Track"), # pylint: disable=unnecessary-lambda-assignment + ('verified', "Verified Track")] + +from bulk_grades.api import ScoreCSVProcessor, get_score, set_score + +_ = lambda text: text # pylint: disable=unnecessary-lambda-assignment + +log = logging.getLogger(__name__) + + +@XBlock.needs('settings') +@XBlock.needs('i18n') +@XBlock.needs('user') +class StaffGradedXBlock(StudioEditableXBlockMixin, ScorableXBlockMixin, XBlock): # pylint: disable=abstract-method + """ + Staff Graded Points block + """ + display_name = String( + display_name=_("Display Name"), + help=_("The display name for this component."), + scope=Scope.settings, + default=_("Staff Graded Points"), + ) + instructions = String( + display_name=_("Instructions"), + help=_("The instructions to the learner. Markdown format"), + scope=Scope.content, + multiline_editor=True, + default=_("Your results will be graded offline"), + runtime_options={'multiline_editor': 'html'}, + ) + weight = Float( + display_name="Problem Weight", + help=_( + "Enter the number of points possible for this component. " + "The default value is 1.0. " + ), + default=1.0, + scope=Scope.settings, + values={"min": 0}, + ) + has_score = True + + editable_fields = ('display_name', 'instructions', 'weight') + + def _get_current_username(self): + return self.runtime.service(self, 'user').get_current_user().opt_attrs.get( + 'edx-platform.username') + + def resource_string(self, path): + """Handy helper for getting resources from our kit.""" + data = pkg_resources.resource_string(__name__, path) + return data.decode("utf8") + + def student_view(self, context=None): + """ + The primary view of the StaffGradedXBlock, shown to students + when viewing courses. + """ + frag = Fragment() + frag.add_css(self.resource_string("static/css/staff_graded.css")) + loader = ResourceLoader(__name__) + _ = self.runtime.service(self, "i18n").ugettext + + # Add i18n js + statici18n_js_url = self._get_statici18n_js_url() + if statici18n_js_url: + frag.add_javascript_url(self.runtime.local_resource_url(self, statici18n_js_url)) + + frag.add_javascript(self.resource_string("static/js/src/staff_graded.js")) + frag.initialize_js('StaffGradedXBlock') + + context['id'] = self.location.html_id() # pylint: disable=no-member + context['instructions'] = markdown.markdown(self.instructions) + context['display_name'] = self.display_name + context['is_staff'] = self.runtime.user_is_staff + + course_id = self.location.course_key # pylint: disable=no-member + context['available_cohorts'] = [cohort.name for cohort in get_course_cohorts(course_id=course_id)] + context['available_tracks'] = [ + (mode.slug, mode.name) for mode in # pylint: disable=no-member + modes_for_course(course_id, only_selectable=False) + ] + + if context['is_staff']: + from crum import get_current_request # pylint: disable=import-outside-toplevel + from django.middleware.csrf import get_token # pylint: disable=import-outside-toplevel + context['import_url'] = self.runtime.handler_url(self, "csv_import_handler") + context['export_url'] = self.runtime.handler_url(self, "csv_export_handler") + context['poll_url'] = self.runtime.handler_url(self, "get_results_handler") + context['csrf_token'] = get_token(get_current_request()) + frag.add_javascript(loader.load_unicode('static/js/src/staff_graded.js')) + frag.initialize_js('StaffGradedProblem', + json_args={k: context[k] + for k + in ('csrf_token', 'import_url', 'export_url', 'poll_url', 'id')}) + + try: + score = get_score(self.location, self.runtime.user_id) or {} # pylint: disable=no-member + context['grades_available'] = True + except NoSuchServiceError: + context['grades_available'] = False + else: + if score: + grade = score['score'] + context['score_string'] = _('{score} / {total} points').format(score=grade, total=self.weight) + else: + context['score_string'] = _('{total} points possible').format(total=self.weight) + frag.add_content(loader.render_django_template('static/html/staff_graded.html', context)) + return frag + + # TO-DO: change this to create the scenarios you'd like to see in the + # workbench while developing your XBlock. + @staticmethod + def workbench_scenarios(): + """A canned scenario for display in the workbench.""" + return [ + ("StaffGradedXBlock", + """ + """), + ("Multiple StaffGradedXBlock", + """ + + + + + """), + ] + + @staticmethod + def _get_statici18n_js_url(): + """ + Returns the Javascript translation file for the currently selected language, if any. + Defaults to English if available. + """ + from django.utils import translation # pylint: disable=import-outside-toplevel + locale_code = translation.get_language() + if locale_code is None: + return None + text_js = 'public/js/translations/{locale_code}/text.js' + lang_code = locale_code.split('-')[0] + for code in (locale_code, lang_code, 'en'): + loader = ResourceLoader(__name__) + if pkg_resources.resource_exists( + loader.module_name, text_js.format(locale_code=code)): + return text_js.format(locale_code=code) + return None + + @staticmethod + def get_dummy(): + """ + Dummy method to generate initial i18n + """ + from django.utils import translation # pylint: disable=import-outside-toplevel + return translation.gettext_noop('Dummy') + + @XBlock.handler + def csv_import_handler(self, request, suffix=''): # pylint: disable=unused-argument + """ + Endpoint that handles CSV uploads. + """ + if not self.runtime.user_is_staff: + return Response('not allowed', status_code=403) + + _ = self.runtime.service(self, "i18n").ugettext + + try: + score_file = request.POST['csv'].file + except KeyError: + data = {'error_rows': [1], 'error_messages': [_('missing file')]} + else: + log.info('Processing %d byte score file %s for %s', score_file.size, score_file.name, self.location) # pylint: disable=no-member + block_id = self.location # pylint: disable=no-member + block_weight = self.weight + processor = ScoreCSVProcessor( + block_id=str(block_id), + max_points=block_weight, + user_id=self.runtime.user_id) + processor.process_file(score_file, autocommit=True) + data = processor.status() + log.info('Processed file %s for %s -> %s saved, %s processed, %s error. (async=%s)', + score_file.name, + block_id, + data.get('saved', 0), + data.get('total', 0), + len(data.get('error_rows', [])), + data.get('waiting', False)) + return Response(json_body=data) + + @XBlock.handler + def csv_export_handler(self, request, suffix=''): # pylint: disable=unused-argument + """ + Endpoint that handles CSV downloads. + """ + if not self.runtime.user_is_staff: + return Response('not allowed', status_code=403) + + track = request.GET.get('track', None) + cohort = request.GET.get('cohort', None) + + buf = io.StringIO() + ScoreCSVProcessor( + block_id=str(self.location), # pylint: disable=no-member + max_points=self.weight, + display_name=self.display_name, + track=track, + cohort=cohort).write_file(buf) + resp = Response(buf.getvalue()) + resp.content_type = 'text/csv' + resp.content_disposition = f'attachment; filename="{self.location}.csv"' # pylint: disable=no-member + return resp + + @XBlock.handler + def get_results_handler(self, request, suffix=''): # pylint: disable=unused-argument + """ + Endpoint to poll for celery results. + """ + if not self.runtime.user_is_staff: + return Response('not allowed', status_code=403) + try: + result_id = request.POST['result_id'] + except KeyError: + data = {'message': 'missing'} + else: + results = ScoreCSVProcessor().get_deferred_result(result_id) + if results.ready(): + data = results.get() + log.info('Got results from celery %r', data) + else: + data = {'waiting': True, 'result_id': result_id} + log.info('Still waiting for %s', result_id) + return Response(json_body=data) + + def max_score(self): + return self.weight + + def get_score(self): + """ + Return a raw score already persisted on the XBlock. Should not + perform new calculations. + + Returns: + Score(raw_earned=float, raw_possible=float) + """ + score = get_score(self.runtime.user_id, self.location) # pylint: disable=no-member + score = score or {'grade': 0, 'max_grade': 1} + return Score(raw_earned=score['grade'], raw_possible=score['max_grade']) + + def set_score(self, score): + """ + Persist a score to the XBlock. + + The score is a named tuple with a raw_earned attribute and a + raw_possible attribute, reflecting the raw earned score and the maximum + raw score the student could have earned respectively. + + Arguments: + score: Score(raw_earned=float, raw_possible=float) + + Returns: + None + """ + state = json.dumps({'grader': self._get_current_username()}) + set_score(self.location, # pylint: disable=no-member + self.runtime.user_id, + score.raw_earned, + score.raw_possible, + state=state) + + def publish_grade(self): + pass diff --git a/build/lib/staff_graded/static/README.txt b/build/lib/staff_graded/static/README.txt new file mode 100644 index 0000000..0472ef6 --- /dev/null +++ b/build/lib/staff_graded/static/README.txt @@ -0,0 +1,19 @@ +This static directory is for files that should be included in your kit as plain +static files. + +You can ask the runtime for a URL that will retrieve these files with: + + url = self.runtime.local_resource_url(self, "static/js/lib.js") + +The default implementation is very strict though, and will not serve files from +the static directory. It will serve files from a directory named "public". +Create a directory alongside this one named "public", and put files there. +Then you can get a url with code like this: + + url = self.runtime.local_resource_url(self, "public/js/lib.js") + +The sample code includes a function you can use to read the content of files +in the static directory, like this: + + frag.add_javascript(self.resource_string("static/js/my_block.js")) + diff --git a/build/lib/staff_graded/static/css/staff_graded.css b/build/lib/staff_graded/static/css/staff_graded.css new file mode 100644 index 0000000..44acc98 --- /dev/null +++ b/build/lib/staff_graded/static/css/staff_graded.css @@ -0,0 +1,14 @@ +/* CSS for StaffGradedXBlock */ + +.staff_graded_block .count { + font-weight: bold; +} + +.staff_graded_block p { + cursor: pointer; +} + +.staff_graded_block .message { + color: red; + margin-left: 1.1em; +} \ No newline at end of file diff --git a/build/lib/staff_graded/static/html/staff_graded.html b/build/lib/staff_graded/static/html/staff_graded.html new file mode 100644 index 0000000..4dd9536 --- /dev/null +++ b/build/lib/staff_graded/static/html/staff_graded.html @@ -0,0 +1,65 @@ +
+

{{display_name}}

+
+ {{score_string}} +
+ +

+ {{instructions|safe}} +

+ + {% if is_staff and grades_available %} + {% load i18n %} +
+
+
+
    +
  • {% trans "Step 1:" %} {% trans "Export scores." %}
    +

    {% trans "Download a grading CSV and use this as a template to assign scores for this problem." %}

    +

    + {% trans "Choose a track and/or cohort:" %} + + +

    +

    {% trans "export scores" %}

    +
  • +
  • {% trans "Step 2:" %} {% trans "(On your own machine) Fill out grades." %}
    +

    {% trans "Open the CSV in a spreadsheet editor and assign scores to learners via “New Points” field. Leave scores that you don’t want to change blank." %}

    +
  • +
  • {% trans "Step 3:" %} {% trans "Import scores." %}
    +

    {% trans "Upload the filled out CSV. Learners will immediately see their grades after import completes." %}

    +

    {% trans "Note: Supports file sizes up to 4MB." %}

    +
    + + +
    +
  • +
+ + + +
+
+ {% else %}{% comment non staff view %}{% endcomment %} + {% endif %} +
diff --git a/build/lib/staff_graded/static/js/src/staff_graded.js b/build/lib/staff_graded/static/js/src/staff_graded.js new file mode 100644 index 0000000..38d20e6 --- /dev/null +++ b/build/lib/staff_graded/static/js/src/staff_graded.js @@ -0,0 +1,109 @@ +(function() { + 'use strict' + + function doneLoading(blockId, data) { + $(`#${blockId}-spinner`).hide(); + if (data.error_rows.length || data.error_messages.length) { + var message = ''; + if (data.error_rows.length) { + message += interpolate_text( + ngettext('{error_count} error. Please try again. ', + '{error_count} errors. Please try again. ', + data.error_rows.length), + { error_count: data.error_rows.length }); + } + if (data.error_messages.length) { + message += '
'; + message += data.error_messages; + } + } else { + var message = interpolate_text( + ngettext('Processed {row_count} row. ', + 'Processed {row_count} rows. ', + data.total), { row_count:data.total }) + + interpolate_text( + ngettext('Updated scores for {row_count} learner.', + 'Updated scores for {row_count} learners.', + data.saved), { row_count: data.saved }); + } + $(`#${blockId}-status`).show(); + $(`#${blockId}-status .message`).html(message); + }; + + function pollResults(blockId, poll_url, result_id) { + $.ajax({ + url: poll_url, + type: 'POST', + data: {result_id: result_id}, + success: function(data) { + if (data.waiting) { + setTimeout(function(){ + pollResults(blockId, poll_url, result_id); + }, 1000); + } else { + doneLoading(blockId, data); + } + } + }); + }; + + + this.StaffGradedProblem = function(runtime, element, json_args) { + var $element = $(element); + var fileInput = $element.find('.file-input'); + var $exportButton = $element.find('.export-button'); + fileInput.change(function(e){ + var firstFile = this.files[0]; + var self = this; + if (firstFile == undefined) { + return; + } else if (firstFile.size > 4194303) { + var message = gettext('Files must be less than 4MB. Please split the file into smaller chunks and upload again.'); + $(`#${json_args.id}-status`).show(); + $(`#${json_args.id}-status .message`).html(message); + return; + } + var formData = new FormData(); + formData.append('csrfmiddlewaretoken', json_args.csrf_token); + formData.append('csv', firstFile); + + $element.find('.filename').html(firstFile.name); + $element.find('.status').hide(); + $element.find('.spinner').show(); + $.ajax({ + url : json_args.import_url, + type : 'POST', + data : formData, + processData: false, // tell jQuery not to process the data + contentType: false, // tell jQuery not to set contentType + success : function(data) { + self.value = ''; + if (data.waiting) { + setTimeout(function() { + pollResults(json_args.id, json_args.poll_url, data.result_id); + }, 1000); + } else { + doneLoading(json_args.id, data); + } + } + }); + + }); + + $exportButton.click(function(e) { + e.preventDefault(); + var url = $exportButton.attr('href') + '?' + $.param( + { + track: $element.find('.track-field').val(), + cohort: $element.find('.cohort-field').val() + } + ); + location.href = url; + }); + + }; + + this.StaffGradedXBlock = function(runtime, element) { + }; + +}).call(this); diff --git a/setup.py b/setup.py index ab5e889..61d2047 100644 --- a/setup.py +++ b/setup.py @@ -25,24 +25,96 @@ def package_data(pkg, roots): def load_requirements(*requirements_paths): """ Load all requirements from the specified requirements files. + + Requirements will include any constraints from files specified + with -c in the requirements files. Returns a list of requirement strings. """ - requirements = set() - for path in requirements_paths: - with open(path, encoding='utf-8') as reqs: - requirements.update( - line.split('#')[0].strip() for line in reqs - if is_requirement(line.strip()) + # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why. + + # e.g. {"django": "Django", "confluent-kafka": "confluent_kafka[avro]"} + by_canonical_name = {} + + def check_name_consistent(package): + """ + Raise exception if package is named different ways. + + This ensures that packages are named consistently so we can match + constraints to packages. It also ensures that if we require a package + with extras we don't constrain it without mentioning the extras (since + that too would interfere with matching constraints.) + """ + canonical = package.lower().replace('_', '-').split('[')[0] + seen_spelling = by_canonical_name.get(canonical) + if seen_spelling is None: + by_canonical_name[canonical] = package + elif seen_spelling != package: + raise Exception( + f'Encountered both "{seen_spelling}" and "{package}" in requirements ' + 'and constraints files; please use just one or the other.' ) - return list(requirements) + + requirements = {} + constraint_files = set() + + # groups "pkg<=x.y.z,..." into ("pkg", "<=x.y.z,...") + re_package_name_base_chars = r"a-zA-Z0-9\-_." # chars allowed in base package name + # Two groups: name[maybe,extras], and optionally a constraint + requirement_line_regex = re.compile( + r"([%s]+(?:\[[%s,\s]+\])?)([<>=][^#\s]+)?" + % (re_package_name_base_chars, re_package_name_base_chars) + ) + + def add_version_constraint_or_raise(current_line, current_requirements, add_if_not_present): + regex_match = requirement_line_regex.match(current_line) + if regex_match: + package = regex_match.group(1) + version_constraints = regex_match.group(2) + check_name_consistent(package) + existing_version_constraints = current_requirements.get(package, None) + # It's fine to add constraints to an unconstrained package, + # but raise an error if there are already constraints in place. + if existing_version_constraints and existing_version_constraints != version_constraints: + raise BaseException(f'Multiple constraint definitions found for {package}:' + f' "{existing_version_constraints}" and "{version_constraints}".' + f'Combine constraints into one location with {package}' + f'{existing_version_constraints},{version_constraints}.') + if add_if_not_present or package in current_requirements: + current_requirements[package] = version_constraints + + # Read requirements from .in files and store the path to any + # constraint files that are pulled in. + for path in requirements_paths: + with open(path) as reqs: + for line in reqs: + if is_requirement(line): + add_version_constraint_or_raise(line, requirements, True) + if line and line.startswith('-c') and not line.startswith('-c http'): + constraint_files.add(os.path.dirname(path) + '/' + line.split('#')[0].replace('-c', '').strip()) + + # process constraint files: add constraints to existing requirements + for constraint_file in constraint_files: + with open(constraint_file) as reader: + for line in reader: + if is_requirement(line): + add_version_constraint_or_raise(line, requirements, False) + + # process back into list of pkg><=constraints strings + constrained_requirements = [f'{pkg}{version or ""}' for (pkg, version) in sorted(requirements.items())] + return constrained_requirements def is_requirement(line): """ - Return True if the requirement line is a package requirement; - that is, it is not blank, a comment, a URL, or an included file. + Return True if the requirement line is a package requirement. + + Returns: + bool: True if the line is not blank, a comment, + a URL, or an included file """ - return line and not line.startswith(('-r', '#', '-e', 'git+', '-c')) + # UPDATED VIA SEMGREP - if you need to remove/modify this method remove this line and add a comment specifying why + + return line and line.strip() and not line.startswith(('-r', '#', '-e', 'git+', '-c')) def get_version(file_path):