diff --git a/.coveragerc b/.coveragerc index d505abcaf..0592b313c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,8 +1,6 @@ [run] include = */codewof/* omit = *migrations*, *tests* -plugins = - django_coverage_plugin branch = True [report] diff --git a/.travis.yml b/.travis.yml index cd509782b..2d032d83f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,9 +13,9 @@ install: jobs: include: - stage: test - # script: ./dev ci test_suite - # name: "Run test suite" - script: ./dev ci style + script: ./dev ci test_suite + name: "Run test suite" + - script: ./dev ci style name: "Run style checker" - stage: development deployment script: skip diff --git a/CHANGELOG.md b/CHANGELOG.md index bbc6ba48d..a9ae6891f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,52 @@ # Changelog +## 2.0.0 + +Adds gamification elements (points and achievements) to the website, including for all previous submissions for each user. + +- Add gamification system that allows users to earn points and achievements. +- Add ability for the CodeWOF server to recalculate a user's points and achievements from their past attempts. +- Improve the CodeWOF admin site with more information and filtering options. +- Update Django from 2.1.5 to 2.2.3. +- Dependency updates: + - Remove django-coverage-plugin. + - Update coverage from 4.5.4 to 5.1. + - Update django-activeurl from 0.1.12 to 0.2.0. + - Update django-allauth from 0.39.1 to 0.41.0. + - Update django-ckeditor from 5.7.1 to 5.9.0. + - Update django-crispy-forms from 1.7.2 to 1.9.0. + - Update django-debug-toolbar from 2.0 to 2.2. + - Update django-extensions from 2.2.1 to 2.2.9. + - Update django-modeltranslation from 0.13.3 to 0.14.4. + - Update django-model-utils from 3.2.0 to 4.0.0. + - Update django-recaptcha from 2.0.5 to 2.0.6. + - Update django-redis from 4.10.0 to 4.11.0. + - Update django-storages from 1.7.1 to 1.9.1. + - Update google-api-python-client from 1.7.11 to 1.7.12. + - Update google-auth from 1.6.3 to 1.12.0. + - Update google-cloud-logging from 1.12.1 to 1.15.0. + - Update mypy from 0.720 to 0.770. + - Update Pillow from 6.1.0 to 7.0.0. + - Update pydocstyle from 4.0.1 to 5.0.2. + - Update pytest from 5.1.1 to 5.4.1. + - Update pytest-django from 3.5.1 to 3.8.0. + - Update PyYAML from 5.1.2 to 5.3.1. + - Update Sphinx from 2.2.0 to 3.0.4. + ## 1.5.3 + - Use default settings for split health checks. ## 1.5.2 + - Split GCP health check URLs. ## 1.5.1 + - Update GCP health checks. ## 1.5.0 + - Add 7 new questions. - Fix test case where no argument was passed for the total of evens question. Fixes #101 - Fix error in example of the driver speed question. @@ -37,7 +74,7 @@ - Fix bug where whitespace of user code in attempt wasn't shown in admin interface. - Fix bug where sender's email address is not listed on contact us forms sent to admin. - General typo fixes and question clarifications. -- Dependencies changes: +- Dependency updates: - Update django-recaptcha from 2.0.4 to 2.0.5. - Update google-api-python-client from 1.7.10 to 1.7.11. - Update pydocstyle from 4.0.0 to 4.0.1. diff --git a/Dockerfile b/Dockerfile index d687ac9a4..36ebe48b7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # This Dockerfile is based off the Google App Engine Python runtime image # https://github.com/GoogleCloudPlatform/python-runtime -FROM uccser/django:2.1.5 +FROM uccser/django:2.2.3 # Add metadata to Docker image LABEL maintainer="csse-education-research@canterbury.ac.nz" diff --git a/Dockerfile-local b/Dockerfile-local index 6680b1391..3c2188cc6 100644 --- a/Dockerfile-local +++ b/Dockerfile-local @@ -1,6 +1,6 @@ # This Dockerfile is based off the Google App Engine Python runtime image # https://github.com/GoogleCloudPlatform/python-runtime -FROM uccser/django:2.1.5-with-weasyprint +FROM uccser/django:2.2.3-with-weasyprint # Add metadata to Docker image LABEL maintainer="csse-education-research@canterbury.ac.nz" diff --git a/codewof/__init__.py b/codewof/__init__.py new file mode 100644 index 000000000..05a5d4d7a --- /dev/null +++ b/codewof/__init__.py @@ -0,0 +1 @@ +"""Init file for codeWOF.""" diff --git a/codewof/config/__init__.py b/codewof/config/__init__.py index a969c9be4..bcfe02ac3 100644 --- a/codewof/config/__init__.py +++ b/codewof/config/__init__.py @@ -1,6 +1,6 @@ """Configuration for Django system.""" -__version__ = "1.5.3" +__version__ = "2.0.0" __version_info__ = tuple( [ int(num) if num.isdigit() else num diff --git a/codewof/config/settings/base.py b/codewof/config/settings/base.py index b95776d27..0c5551e0b 100644 --- a/codewof/config/settings/base.py +++ b/codewof/config/settings/base.py @@ -5,7 +5,6 @@ import environ from utils.get_upload_filepath import get_upload_path_for_date - # codewof/codewof/config/settings/base.py - 3 = codewof/codewof/ ROOT_DIR = environ.Path(__file__) - 3 @@ -316,6 +315,10 @@ 'ignore_params': 'no' } +ACTIVE_URL_CACHE = True +ACTIVE_URL_CACHE_TIMEOUT = 60 * 60 * 24 # 1 day +ACTIVE_URL_CACHE_PREFIX = 'django_activeurl' + # django-rest-framework # ------------------------------------------------------------------------------ REST_FRAMEWORK = { diff --git a/codewof/config/settings/production.py b/codewof/config/settings/production.py index ab9114eaa..269384447 100644 --- a/codewof/config/settings/production.py +++ b/codewof/config/settings/production.py @@ -8,6 +8,8 @@ # GENERAL # ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = False # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env('DJANGO_SECRET_KEY') # SECURITY WARNING: App Engine's security features ensure that it is safe to @@ -26,7 +28,7 @@ # Exempt Google App Engine cron job URLs from HTTPS to function correctly. SECURE_REDIRECT_EXEMPT = [ - r'^/?cron/.*', + r'^tasks/.*', ] # DATABASES diff --git a/codewof/general/management/commands/sampledata.py b/codewof/general/management/commands/sampledata.py index 99398924c..31877f7ba 100644 --- a/codewof/general/management/commands/sampledata.py +++ b/codewof/general/management/commands/sampledata.py @@ -20,6 +20,14 @@ class Command(management.base.BaseCommand): help = "Add sample data to database." + def add_arguments(self, parser): + """Interprets arguments passed to command.""" + parser.add_argument( + '--skip_backdate', + action='store_true', + help='skip backdate step', + ) + def handle(self, *args, **options): """Automatically called when the sampledata command is given.""" if settings.DEPLOYMENT_TYPE == 'prod' and not settings.DEBUG: @@ -27,6 +35,8 @@ def handle(self, *args, **options): 'This command can only be executed in DEBUG mode on non-production website.' ) + skip = options['skip_backdate'] + # Clear all data print(LOG_HEADER.format('Wipe database')) management.call_command('flush', interactive=False) @@ -50,7 +60,7 @@ def handle(self, *args, **options): primary=True, verified=True ) - print('Admin created.') + print('Admin created.\n') # Create user account user = User.objects.create_user( @@ -67,18 +77,28 @@ def handle(self, *args, **options): primary=True, verified=True ) + UserFactory.create_batch(size=100) - print('Users created.') + print('Users created.\n') # Codewof management.call_command('load_questions') - print('Programming questions loaded.') + print('Programming questions loaded.\n') + + management.call_command('load_achievements') + print('Achievements loaded.\n') # Research StudyFactory.create_batch(size=5) StudyGroupFactory.create_batch(size=15) - print('Research studies loaded.') + print('Research studies loaded.\n') # Attempts AttemptFactory.create_batch(size=50) - print('Attempts loaded.') + print('Attempts loaded.\n') + + # Award points and achievements + if not skip: + management.call_command('backdate_points_and_achievements') + else: + print('Ignoring backdate step as requested.\n') diff --git a/codewof/programming/admin.py b/codewof/programming/admin.py index 8733f527e..71ee28edd 100644 --- a/codewof/programming/admin.py +++ b/codewof/programming/admin.py @@ -8,7 +8,10 @@ QuestionTypeProgram, QuestionTypeFunction, QuestionTypeParsons, - QuestionTypeDebugging + QuestionTypeDebugging, + Profile, + Achievement, + Earned, ) User = get_user_model() @@ -23,10 +26,43 @@ class TestCaseAttemptInline(admin.TabularInline): can_delete = False +class EarnedInline(admin.TabularInline): + """Configuration to show earned achievements inline within profile admin.""" + + model = Earned + extra = 1 + + +class ProfileAdmin(admin.ModelAdmin): + """Configuration for displaying profiles in admin.""" + + list_display = ('user', 'points', 'goal', 'has_backdated') + list_filter = ['goal', 'has_backdated'] + ordering = ('user', ) + inlines = (EarnedInline, ) + + +class AchievementAdmin(admin.ModelAdmin): + """Configuration for displaying achievements in admin.""" + + list_display = ('id_name', 'display_name', 'achievement_tier') + list_filter = ['achievement_tier'] + ordering = ('id_name', ) + + +class EarnedAdmin(admin.ModelAdmin): + """Configuration for displaying earned achievements in admin.""" + + list_display = ('date', 'achievement', 'profile') + list_filter = ['achievement'] + ordering = ('-date', ) + + class AttemptAdmin(admin.ModelAdmin): """Configuration for displaying attempts in admin.""" list_display = ('datetime', 'question', 'profile', 'passed_tests') + list_filter = ['passed_tests', 'question'] ordering = ('-datetime', ) inlines = (TestCaseAttemptInline, ) @@ -43,3 +79,6 @@ class Media: admin.site.register(QuestionTypeFunction) admin.site.register(QuestionTypeParsons) admin.site.register(QuestionTypeDebugging) +admin.site.register(Profile, ProfileAdmin) +admin.site.register(Achievement, AchievementAdmin) +admin.site.register(Earned, EarnedAdmin) diff --git a/codewof/programming/codewof_utils.py b/codewof/programming/codewof_utils.py new file mode 100644 index 000000000..cc867d0bf --- /dev/null +++ b/codewof/programming/codewof_utils.py @@ -0,0 +1,308 @@ +""" +Utility functions for codeWOF system. + +Involves points, achievements, and backdating points and achievements per user. +""" + +import datetime +import json +import logging +import time +import statistics +from dateutil.relativedelta import relativedelta + +from programming.models import ( + Profile, + Attempt, + Achievement, + Earned, +) +from django.http import JsonResponse + +logger = logging.getLogger(__name__) + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'incremental': True, + 'root': { + 'level': 'DEBUG', + }, +} + +# Number of points awarded for achieving each goal +POINTS_ACHIEVEMENT = 10 +POINTS_SOLUTION = 10 + + +def add_points(question, profile, attempt): + """ + Add appropriate number of points (if any) to user profile after a question is answered. + + Adds points to a user's profile for when the user answers a question correctly for the first time. + Subsequent correct answers should not award any points. + """ + attempts = Attempt.objects.filter(question=question, profile=profile) + is_first_correct = len(attempts.filter(passed_tests=True)) == 1 + + if attempt.passed_tests and is_first_correct: + profile.points += POINTS_SOLUTION + + profile.full_clean() + profile.save() + return profile.points + + +def save_goal_choice(request): + """Update user's goal choice in database.""" + request_json = json.loads(request.body.decode('utf-8')) + if request.user.is_authenticated: + user = request.user + profile = user.profile + + goal_choice = request_json['goal_choice'] + profile.goal = int(goal_choice) + profile.full_clean() + profile.save() + + return JsonResponse({}) + + +def get_days_consecutively_answered(profile, user_attempts=None): + """ + Get the number of consecutive days with questions attempted. + + Gets all datetimes of attempts for the given user profile, and checks for the longest continuous "streak" of + days where attempts were made. Returns an integer of the longest attempt "streak". + """ + if user_attempts is None: + user_attempts = Attempt.objects.filter(profile=profile) + + # get datetimes from attempts in date form) + attempts = user_attempts.datetimes('datetime', 'day', 'DESC') + + if len(attempts) <= 0: + return 0 + + # first attempt is the start of the first streak + streak = 1 + highest_streak = 0 + expected_date = attempts[0].date() - datetime.timedelta(days=1) + + for attempt in attempts[1:]: + if attempt.date() == expected_date: + # continue the streak + streak += 1 + else: + # streak has ended + if streak > highest_streak: + highest_streak = streak + streak = 1 + # compare the next item to yesterday + expected_date = attempt.date() - datetime.timedelta(days=1) + + if streak > highest_streak: + highest_streak = streak + + return highest_streak + + +def get_questions_answered_in_past_month(profile, user_attempts=None): + """Get the number questions successfully answered in the past month.""" + if user_attempts is None: + user_attempts = Attempt.objects.filter(profile=profile) + + today = datetime.datetime.now().replace(tzinfo=None) + relativedelta(days=1) + last_month = today - relativedelta(months=1) + solved = user_attempts.filter(datetime__gte=last_month.date(), passed_tests=True) + return len(solved) + + +def check_achievement_conditions(profile, user_attempts=None): + """ + Check if the user profile has earned new achievements for their profile. + + Checks if the user has received each available achievement. + If not, check if the user has earned these achievements. + Achievements available to be checked for are profile creation, number of attempts made, + number of questions answered, and number of days with consecutive attempts. + + An achievement will not be removed if the user had earned it before but now doesn't meet the conditions + """ + if user_attempts is None: + user_attempts = Attempt.objects.filter(profile=profile) + + achievement_objects = Achievement.objects.all() + earned_achievements = profile.earned_achievements.all() + new_achievement_names = "" + new_achievement_objects = [] + + # account creation achievement + try: + creation_achievement = achievement_objects.get(id_name="create-account") + if creation_achievement not in earned_achievements: + # create a new account creation + Earned.objects.create( + profile=profile, + achievement=creation_achievement + ) + new_achievement_names += creation_achievement.display_name + "\n" + new_achievement_objects.append(creation_achievement) + except Achievement.DoesNotExist: + logger.warning("No such achievement: create-account") + pass + + # check questions solved achievements + try: + question_achievements = achievement_objects.filter(id_name__contains="questions-solved") + solved = user_attempts.filter(passed_tests=True).distinct('question__slug') + for question_achievement in question_achievements: + if question_achievement not in earned_achievements: + num_questions = int(question_achievement.id_name.split("-")[2]) + if len(solved) >= num_questions: + Earned.objects.create( + profile=profile, + achievement=question_achievement + ) + new_achievement_names += question_achievement.display_name + "\n" + new_achievement_objects.append(question_achievement) + else: + # hasn't achieved the current achievement tier so won't achieve any higher ones + break + except Achievement.DoesNotExist: + logger.warning("No such achievements: questions-solved") + pass + + # checked questions attempted achievements + try: + attempt_achievements = achievement_objects.filter(id_name__contains="attempts-made") + attempted = user_attempts + for attempt_achievement in attempt_achievements: + if attempt_achievement not in earned_achievements: + num_questions = int(attempt_achievement.id_name.split("-")[2]) + if len(attempted) >= num_questions: + Earned.objects.create( + profile=profile, + achievement=attempt_achievement + ) + new_achievement_names += attempt_achievement.display_name + "\n" + new_achievement_objects.append(attempt_achievement) + else: + # hasn't achieved the current achievement tier so won't achieve any higher ones + break + except Achievement.DoesNotExist: + logger.warning("No such achievements: attempts-made") + pass + + # consecutive days logged in achievements + num_consec_days = get_days_consecutively_answered(profile, user_attempts=user_attempts) + consec_achievements = achievement_objects.filter(id_name__contains="consecutive-days") + for consec_achievement in consec_achievements: + if consec_achievement not in earned_achievements: + n_days = int(consec_achievement.id_name.split("-")[2]) + if n_days <= num_consec_days: + Earned.objects.create( + profile=profile, + achievement=consec_achievement + ) + new_achievement_names += consec_achievement.display_name + "\n" + new_achievement_objects.append(consec_achievement) + else: + # hasn't achieved the current achievement tier so won't achieve any higher ones + break + + new_points = calculate_achievement_points(new_achievement_objects) + profile.points += new_points + profile.full_clean() + profile.save() + return new_achievement_names + + +def calculate_achievement_points(achievements): + """Return the number of points earned by the user for new achievements.""" + points = 0 + for achievement in achievements: + points += achievement.achievement_tier * POINTS_ACHIEVEMENT + return points + + +def backdate_user(profile): + """Perform backdate of a single user profile.""" + attempts = Attempt.objects.filter(profile=profile) + profile = backdate_achievements(profile, user_attempts=attempts) + profile = backdate_points(profile, user_attempts=attempts) + profile.has_backdated = True + profile.full_clean() + profile.save() + + +def backdate_points_and_achievements(n=-1, ignoreFlags=True): + """Perform batch backdate of all points and achievements for n profiles in the system.""" + backdate_achievements_times = [] + backdate_points_times = [] + time_before = time.perf_counter() + profiles = Profile.objects.all() + if not ignoreFlags: + profiles = profiles.filter(has_backdated=False) + if (n > 0): + profiles = profiles[:n] + num_profiles = len(profiles) + all_attempts = Attempt.objects.all() + for i in range(num_profiles): + # The commented out part below seems to break travis somehow + print("Backdating user: {}/{}".format(str(i + 1), str(num_profiles))) # , end="\r") + profile = profiles[i] + if not profile.has_backdated or ignoreFlags: + attempts = all_attempts.filter(profile=profile) + + achievements_time_before = time.perf_counter() + profile = backdate_achievements(profile, user_attempts=attempts) + achievements_time_after = time.perf_counter() + backdate_achievements_times.append(achievements_time_after - achievements_time_before) + + points_time_before = time.perf_counter() + profile = backdate_points(profile, user_attempts=attempts) + points_time_after = time.perf_counter() + backdate_points_times.append(points_time_after - points_time_before) + # save profile when update is completed + profile.has_backdated = True + profile.full_clean() + profile.save() + else: + print("User {} has already been backdated".format(str(i + 1))) + time_after = time.perf_counter() + print("\nBackdate complete.") + + duration = time_after - time_before + + if len(backdate_achievements_times) > 0 and len(backdate_points_times) > 0: + achievements_ave = statistics.mean(backdate_achievements_times) + logger.debug(f"Average time per user to backdate achievements: {achievements_ave:0.4f} seconds") + + points_ave = statistics.mean(backdate_points_times) + logger.debug(f"Average time per user to backdate points: {points_ave:0.4f} seconds") + + average = duration / num_profiles + logger.debug(f"Backdate duration {duration:0.4f} seconds, average per user {average:0.4f} seconds") + else: + logger.debug(f"No users were backdated") + logger.debug(f"Backdate duration {duration:0.4f} seconds") + + +def backdate_points(profile, user_attempts=None): + """Re-calculate points for the user profile.""" + if user_attempts is None: + user_attempts = Attempt.objects.filter(profile=profile) + + num_correct_attempts = len(user_attempts.filter(passed_tests=True).distinct('question__slug')) + profile.points = num_correct_attempts * POINTS_SOLUTION + + for achievement in profile.earned_achievements.all(): + profile.points += POINTS_ACHIEVEMENT * achievement.achievement_tier + return profile + + +def backdate_achievements(profile, user_attempts=None): + """Re-check the profile for achievements earned.""" + check_achievement_conditions(profile, user_attempts=user_attempts) + return profile diff --git a/codewof/programming/management/commands/backdate_points_and_achievements.py b/codewof/programming/management/commands/backdate_points_and_achievements.py new file mode 100644 index 000000000..265f96df8 --- /dev/null +++ b/codewof/programming/management/commands/backdate_points_and_achievements.py @@ -0,0 +1,32 @@ +"""Module for the custom Django backdate_points_and_achievements command.""" + +from django.core.management.base import BaseCommand +from programming.codewof_utils import backdate_points_and_achievements + + +class Command(BaseCommand): + """Required command class for the custom Django backdate command.""" + + help = 'Loads questions into the database' + + def add_arguments(self, parser): + """Interprets arguments passed to command.""" + parser.add_argument( + '--ignore_flags', + action='store_true', + help='ignore status of backdate flags', + ) + parser.add_argument( + '--profiles', + default=-1, + help='number of profiles to backdate', + ) + + def handle(self, *args, **options): + """Automatically called when the backdate command is given.""" + print("Backdating points and achievements\n") + ignoreFlags = options['ignore_flags'] + number = int(options['profiles']) + if ignoreFlags and number > 0: + raise ValueError("If ignoring backdate flags you must backdate all profiles.") + backdate_points_and_achievements(number, ignoreFlags) diff --git a/codewof/programming/management/commands/load_achievements.py b/codewof/programming/management/commands/load_achievements.py new file mode 100644 index 000000000..83ddcbd51 --- /dev/null +++ b/codewof/programming/management/commands/load_achievements.py @@ -0,0 +1,148 @@ +"""Module for the custom Django load_achievements command.""" + +from django.core.management.base import BaseCommand +from programming.models import Achievement + +# TODO: Consider relocating to a yaml file like the questions +ACHIEVEMENTS = [ + { + 'id_name': 'create-account', + 'display_name': 'Created an account!', + 'description': 'Created your very own account', + 'icon_name': 'img/icons/achievements/icons8-achievement-create-account-48.png', + 'achievement_tier': 0, + 'parent': None + }, + { + 'id_name': 'questions-solved-100', + 'display_name': 'Solved one hundred questions!', + 'description': 'Solved one hundred questions', + 'icon_name': 'img/icons/achievements/icons8-question-solved-gold-50.png', + 'achievement_tier': 4, + 'parent': None + }, + { + 'id_name': 'questions-solved-10', + 'display_name': 'Solved ten questions!', + 'description': 'Solved ten questions', + 'icon_name': 'img/icons/achievements/icons8-question-solved-silver-50.png', + 'achievement_tier': 3, + 'parent': 'questions-solved-100' + }, + { + 'id_name': 'questions-solved-5', + 'display_name': 'Solved five questions!', + 'description': 'Solved five questions', + 'icon_name': 'img/icons/achievements/icons8-question-solved-bronze-50.png', + 'achievement_tier': 2, + 'parent': 'questions-solved-10' + }, + { + 'id_name': 'questions-solved-1', + 'display_name': 'Solved one question!', + 'description': 'Solved your very first question', + 'icon_name': 'img/icons/achievements/icons8-question-solved-black-50.png', + 'achievement_tier': 1, + 'parent': 'questions-solved-5' + }, + { + 'id_name': 'attempts-made-100', + 'display_name': 'Made one hundred question attempts!', + 'description': 'Attempted one hundred questions', + 'icon_name': 'img/icons/achievements/icons8-attempt-made-gold-50.png', + 'achievement_tier': 4, + 'parent': None + }, + { + 'id_name': 'attempts-made-10', + 'display_name': 'Made ten question attempts!', + 'description': 'Attempted ten questions', + 'icon_name': 'img/icons/achievements/icons8-attempt-made-silver-50.png', + 'achievement_tier': 3, + 'parent': 'attempts-made-100' + }, + { + 'id_name': 'attempts-made-5', + 'display_name': 'Made five question attempts!', + 'description': 'Attempted five questions', + 'icon_name': 'img/icons/achievements/icons8-attempt-made-bronze-50.png', + 'achievement_tier': 2, + 'parent': 'attempts-made-10' + }, + { + 'id_name': 'attempts-made-1', + 'display_name': 'Made your first question attempt!', + 'description': 'Attempted one question', + 'icon_name': 'img/icons/achievements/icons8-attempt-made-black-50.png', + 'achievement_tier': 1, + 'parent': 'attempts-made-5' + }, + { + 'id_name': 'consecutive-days-28', + 'display_name': 'Worked on coding every day for four weeks!', + 'description': 'Attempted at least one question every day for four weeks', + 'icon_name': 'img/icons/achievements/icons8-calendar-28-50.png', + 'achievement_tier': 5, + 'parent': None + }, + { + 'id_name': 'consecutive-days-21', + 'display_name': 'Worked on coding every day for three weeks!', + 'description': 'Attempted at least one question every day for three weeks', + 'icon_name': 'img/icons/achievements/icons8-calendar-21-50.png', + 'achievement_tier': 4, + 'parent': 'consecutive-days-28' + }, + { + 'id_name': 'consecutive-days-14', + 'display_name': 'Worked on coding every day for two weeks!', + 'description': 'Attempted at least one question every day for two weeks', + 'icon_name': 'img/icons/achievements/icons8-calendar-14-50.png', + 'achievement_tier': 3, + 'parent': 'consecutive-days-21' + }, + { + 'id_name': 'consecutive-days-7', + 'display_name': 'Worked on coding every day for one week!', + 'description': 'Attempted at least one question every day for one week', + 'icon_name': 'img/icons/achievements/icons8-calendar-7-50.png', + 'achievement_tier': 2, + 'parent': 'consecutive-days-14' + }, + { + 'id_name': 'consecutive-days-2', + 'display_name': 'Worked on coding for two days in a row!', + 'description': 'Attempted at least one question two days in a row', + 'icon_name': 'img/icons/achievements/icons8-calendar-2-50.png', + 'achievement_tier': 1, + 'parent': 'consecutive-days-7' + }, +] + + +class Command(BaseCommand): + """Required command class for the custom Django load_achievements command. + + Future plan: Create full loader like the load_questions command + """ + + help = 'Loads achievements into the database' + + def handle(self, *args, **options): + """Automatically called when the load_achievements command is given.""" + all_achievements = {} + + for achievement in ACHIEVEMENTS: + all_achievements[achievement['id_name']], created = Achievement.objects.update_or_create( + id_name=achievement['id_name'], + defaults={ + 'display_name': achievement['display_name'], + 'description': achievement['description'], + 'icon_name': achievement['icon_name'], + 'achievement_tier': achievement['achievement_tier'], + 'parent': None if achievement['parent'] is None else all_achievements[achievement['parent']] + } + ) + print("{} achievement: {}".format("Created" if created else "Updated", achievement['id_name'])) + + print("{} achievements loaded!\n".format(len(all_achievements))) diff --git a/codewof/programming/management/commands/raise_backdate_flags.py b/codewof/programming/management/commands/raise_backdate_flags.py new file mode 100644 index 000000000..48906e182 --- /dev/null +++ b/codewof/programming/management/commands/raise_backdate_flags.py @@ -0,0 +1,20 @@ +"""Module for the custom Django raise_backdate_flags command.""" + +from django.core.management.base import BaseCommand +from programming.models import Profile + + +class Command(BaseCommand): + """Required command class for the custom Django raise backdate flags command.""" + + help = 'Raise flags for all user profiles, requiring them to be backdated' + + def handle(self, *args, **options): + """Automatically called when the raise_backdate_flags command is given.""" + print("Raising backdate flags") + profiles = Profile.objects.all() + for profile in profiles: + profile.has_backdated = False + profile.full_clean() + profile.save() + print("Completed raising backdate flags\n") diff --git a/codewof/programming/migrations/0003_auto_20190904_1810.py b/codewof/programming/migrations/0003_auto_20190904_1810.py new file mode 100644 index 000000000..d922845fe --- /dev/null +++ b/codewof/programming/migrations/0003_auto_20190904_1810.py @@ -0,0 +1,51 @@ +# Generated by Django 2.1.5 on 2019-09-04 06:10 + +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0002_auto_20190813_1548'), + ] + + operations = [ + migrations.CreateModel( + name='Badge', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('id_name', models.CharField(max_length=100, unique=True)), + ('display_name', models.CharField(max_length=100)), + ('description', models.CharField(max_length=500)), + ('icon_name', models.CharField(max_length=100, null=True)), + ], + ), + migrations.CreateModel( + name='Earned', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateTimeField(default=django.utils.timezone.now)), + ('badge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programming.Badge')), + ('profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='programming.Profile')), + ], + ), + migrations.CreateModel( + name='Token', + fields=[ + ('name', models.CharField(max_length=100, primary_key=True, serialize=False)), + ('token', models.CharField(max_length=500)), + ], + ), + migrations.AlterField( + model_name='attempt', + name='datetime', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name='profile', + name='earned_badges', + field=models.ManyToManyField(through='programming.Earned', to='programming.Badge'), + ), + ] diff --git a/codewof/programming/migrations/0004_badge_badge_tier.py b/codewof/programming/migrations/0004_badge_badge_tier.py new file mode 100644 index 000000000..fdf2cf1af --- /dev/null +++ b/codewof/programming/migrations/0004_badge_badge_tier.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.5 on 2019-09-19 02:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0003_auto_20190904_1810'), + ] + + operations = [ + migrations.AddField( + model_name='badge', + name='badge_tier', + field=models.IntegerField(default=0), + ), + ] diff --git a/codewof/programming/migrations/0005_badge_parent.py b/codewof/programming/migrations/0005_badge_parent.py new file mode 100644 index 000000000..5a1530671 --- /dev/null +++ b/codewof/programming/migrations/0005_badge_parent.py @@ -0,0 +1,19 @@ +# Generated by Django 2.1.5 on 2019-10-22 09:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0004_badge_badge_tier'), + ] + + operations = [ + migrations.AddField( + model_name='badge', + name='parent', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='programming.Badge'), + ), + ] diff --git a/codewof/programming/migrations/0006_profile_attempted_questions.py b/codewof/programming/migrations/0006_profile_attempted_questions.py new file mode 100644 index 000000000..e13b17cc2 --- /dev/null +++ b/codewof/programming/migrations/0006_profile_attempted_questions.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.5 on 2020-04-07 01:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0005_badge_parent'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='attempted_questions', + field=models.ManyToManyField(through='programming.Attempt', to='programming.Question'), + ), + ] diff --git a/codewof/programming/migrations/0007_auto_20200409_1424.py b/codewof/programming/migrations/0007_auto_20200409_1424.py new file mode 100644 index 000000000..67d59dfb7 --- /dev/null +++ b/codewof/programming/migrations/0007_auto_20200409_1424.py @@ -0,0 +1,17 @@ +# Generated by Django 2.1.5 on 2020-04-09 02:24 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0006_profile_attempted_questions'), + ] + + operations = [ + migrations.AlterModelOptions( + name='earned', + options={'verbose_name': 'Earned badge', 'verbose_name_plural': 'Badges earned'}, + ), + ] diff --git a/codewof/programming/migrations/0008_auto_20200415_1406.py b/codewof/programming/migrations/0008_auto_20200415_1406.py new file mode 100644 index 000000000..f04ea4eb3 --- /dev/null +++ b/codewof/programming/migrations/0008_auto_20200415_1406.py @@ -0,0 +1,17 @@ +# Generated by Django 2.1.5 on 2020-04-15 02:06 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0007_auto_20200409_1424'), + ] + + operations = [ + migrations.AlterModelOptions( + name='question', + options={'verbose_name': 'Question', 'verbose_name_plural': 'Questions'}, + ), + ] diff --git a/codewof/programming/migrations/0009_auto_20200417_0013.py b/codewof/programming/migrations/0009_auto_20200417_0013.py new file mode 100644 index 000000000..f9469121e --- /dev/null +++ b/codewof/programming/migrations/0009_auto_20200417_0013.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.3 on 2020-04-16 12:13 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0008_auto_20200415_1406'), + ] + + operations = [ + migrations.AlterModelOptions( + name='badge', + options={'ordering': ['badge_tier']}, + ), + ] diff --git a/codewof/programming/migrations/0010_profile_has_backdated.py b/codewof/programming/migrations/0010_profile_has_backdated.py new file mode 100644 index 000000000..73a470a4e --- /dev/null +++ b/codewof/programming/migrations/0010_profile_has_backdated.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.3 on 2020-04-30 00:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0009_auto_20200417_0013'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='has_backdated', + field=models.BooleanField(default=True), + ), + ] diff --git a/codewof/programming/migrations/0011_auto_20200519_1230.py b/codewof/programming/migrations/0011_auto_20200519_1230.py new file mode 100644 index 000000000..cc89b9a1e --- /dev/null +++ b/codewof/programming/migrations/0011_auto_20200519_1230.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.3 on 2020-05-19 00:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0010_profile_has_backdated'), + ] + + operations = [ + migrations.AlterField( + model_name='profile', + name='has_backdated', + field=models.BooleanField(default=False), + ), + ] diff --git a/codewof/programming/migrations/0012_auto_20200608_1252.py b/codewof/programming/migrations/0012_auto_20200608_1252.py new file mode 100644 index 000000000..bd475d2d6 --- /dev/null +++ b/codewof/programming/migrations/0012_auto_20200608_1252.py @@ -0,0 +1,40 @@ +# Generated by Django 2.2.3 on 2020-06-08 00:52 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('programming', '0011_auto_20200519_1230'), + ] + + operations = [ + migrations.RenameModel( + old_name='Badge', + new_name='Achievement', + ), + migrations.AlterModelOptions( + name='achievement', + options={'ordering': ['achievement_tier']}, + ), + migrations.AlterModelOptions( + name='earned', + options={'verbose_name': 'Earned achievement', 'verbose_name_plural': 'Achievements earned'}, + ), + migrations.RenameField( + model_name='achievement', + old_name='badge_tier', + new_name='achievement_tier', + ), + migrations.RenameField( + model_name='earned', + old_name='badge', + new_name='achievement', + ), + migrations.RenameField( + model_name='profile', + old_name='earned_badges', + new_name='earned_achievements', + ), + ] diff --git a/codewof/programming/models.py b/codewof/programming/models.py index 3f8a575cd..d8d55e113 100644 --- a/codewof/programming/models.py +++ b/codewof/programming/models.py @@ -7,6 +7,7 @@ from django.dispatch import receiver from django.urls import reverse from django.core.validators import MinValueValidator, MaxValueValidator +from django.utils import timezone from model_utils.managers import InheritanceManager from utils.TranslatableModel import TranslatableModel @@ -24,8 +25,9 @@ class Profile(models.Model): default=1, validators=[MinValueValidator(1), MaxValueValidator(7)] ) - # earned_badges = models.ManyToManyField('Badge', through='Earned') - # attempted_questions = models.ManyToManyField('Question', through='Attempt') + earned_achievements = models.ManyToManyField('Achievement', through='Earned') + attempted_questions = models.ManyToManyField('Question', through='Attempt') + has_backdated = models.BooleanField(default=False) def __str__(self): """Text representation of a profile.""" @@ -47,41 +49,57 @@ def save_user_profile(sender, instance, **kwargs): instance.profile.save() -# class LoginDay(models.Model): -# profile = models.ForeignKey('Profile', on_delete=models.CASCADE) -# day = models.DateField(auto_now_add=True) +class Achievement(models.Model): + """Achievement that can be earned by a user.""" -# def __str__(self): -# return str(self.day) + id_name = models.CharField(max_length=SMALL, unique=True) + display_name = models.CharField(max_length=SMALL) + description = models.CharField(max_length=LARGE) + icon_name = models.CharField(null=True, max_length=SMALL) + achievement_tier = models.IntegerField(default=0) + parent = models.ForeignKey('Achievement', on_delete=models.CASCADE, null=True, default=None) + def __str__(self): + """Text representation of an achievement.""" + return self.display_name -# class Badge(models.Model): -# id_name = models.CharField(max_length=SMALL, unique=True) -# display_name = models.CharField(max_length=SMALL) -# description = models.CharField(max_length=LARGE) + class Meta: + """Queryset will be ordered by achievement tier.""" -# def __str__(self): -# return self.display_name + ordering = ['achievement_tier'] -# class Earned(models.Model): -# profile = models.ForeignKey('Profile', on_delete=models.CASCADE) -# badge = models.ForeignKey('Badge', on_delete=models.CASCADE) -# date = models.DateTimeField(auto_now_add=True) +class Earned(models.Model): + """Model that documents when an achievement is earned by a user in their profile.""" -# def __str__(self): -# return str(self.date) + profile = models.ForeignKey('Profile', on_delete=models.CASCADE) + achievement = models.ForeignKey('Achievement', on_delete=models.CASCADE) + date = models.DateTimeField(default=timezone.now) -# class Token(models.Model): -# name = models.CharField(max_length=SMALL, primary_key=True) -# token = models.CharField(max_length=LARGE) + class Meta: + """How the name is displayed in the Admin view.""" -# def __str__(self): -# return self.name + verbose_name = "Earned achievement" + verbose_name_plural = "Achievements earned" + + def __str__(self): + """Text representation of an Earned object.""" + return str(self.date) + + +class Token(models.Model): + """Token model for codeWOF.""" + + name = models.CharField(max_length=SMALL, primary_key=True) + token = models.CharField(max_length=LARGE) + + def __str__(self): + """Text representation of a Token.""" + return self.name class Attempt(models.Model): - """An user attempt for a question.""" + """A user attempt for a question.""" profile = models.ForeignKey( 'Profile', @@ -95,9 +113,10 @@ class Attempt(models.Model): 'TestCase', through='TestCaseAttempt' ) - datetime = models.DateTimeField(auto_now_add=True) + datetime = models.DateTimeField(default=timezone.now) user_code = models.TextField() passed_tests = models.BooleanField(default=False) + # skills_hinted = models.ManyToManyField('Skill', blank=True) def __str__(self): @@ -147,9 +166,11 @@ def __str__(self): else: return self.title - # class Meta: - # verbose_name = "Parsons Problem" - # verbose_name_plural = "All Questions & Parsons Problems" + class Meta: + """Meta information for class.""" + + verbose_name = 'Question' + verbose_name_plural = 'Questions' class TestCase(TranslatableModel): diff --git a/codewof/programming/urls.py b/codewof/programming/urls.py index 7f0584aff..f9dd92415 100644 --- a/codewof/programming/urls.py +++ b/codewof/programming/urls.py @@ -16,11 +16,11 @@ app_name = 'programming' urlpatterns = [ - path('', views.IndexView.as_view(), name='home'), path('', include(router.urls)), path('questions/', views.QuestionListView.as_view(), name='question_list'), path('questions/create/', views.CreateView.as_view(), name='create'), path('questions//', views.QuestionView.as_view(), name='question'), + path('tasks/backdate/', views.partial_backdate, name='partial_backdate'), path('ajax/save_question_attempt/', views.save_question_attempt, name='save_question_attempt'), # path('skills//', views.SkillView.as_view(), name="skill"), # path('random//', views.get_random_question, name='random'), diff --git a/codewof/programming/views-old.txt b/codewof/programming/views-old.txt deleted file mode 100644 index 5d0237e49..000000000 --- a/codewof/programming/views-old.txt +++ /dev/null @@ -1,390 +0,0 @@ -# flake8: noqa -# noqa - -from django.views import generic -from django.http import JsonResponse, Http404 -from django.contrib import messages -from django.contrib.auth import login, authenticate, update_session_auth_hash -from django.contrib.auth.forms import PasswordChangeForm -from django.contrib.auth.decorators import login_required -from django.contrib.auth.mixins import LoginRequiredMixin -from django.core.exceptions import ObjectDoesNotExist -from django.core import serializers -import requests -import time -import datetime -import random -import json - -from codewof.models import ( - Profile, - Question, - TestCase, - Attempt, - TestCaseAttempt, -) - -QUESTION_JAVASCRIPT = 'js/question_types/{}.js' - - -class IndexView(generic.base.TemplateView): - """Homepage for CodeWOF.""" - - template_name = 'codewof/index.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - context['questions'] = Question.objects.select_subclasses() - - # if self.request.user.is_authenticated: - # user = User.objects.get(username=self.request.user.username) - # all_questions = Question.objects.all() - # attempted_questions = user.profile.attempted_questions.all() - # new_questions = all_questions.difference(attempted_questions)[:5] - - # history = [] - # for question in new_questions: - # if question.title not in [question['title'] for question in history]: - # history.append({'title': question.title, 'id': question.pk}) - # context['history'] = history - return context - -# class LastAccessMixin(object): -# def dispatch(self, request, *args, **kwargs): -# """update days logged in when user accesses a page with this mixin""" -# if request.user.is_authenticated: -# request.user.last_login = datetime.datetime.now() -# request.user.save(update_fields=['last_login']) - -# profile = request.user.profile -# today = datetime.date.today() - -# login_days = profile.loginday_set.order_by('-day') -# if len(login_days) > 1: -# request.user.last_login = login_days[1].day -# request.user.save(update_fields=['last_login']) - -# if not login_days.filter(day=today).exists(): -# day = LoginDay(profile=profile) -# day.full_clean() -# day.save() - -# return super(LastAccessMixin, self).dispatch(request, *args, **kwargs) - -# def get_random_question(request, current_question_id): -# """redirect to random question user hasn't done, or to index page if there aren't any""" -# valid_question_ids = [] -# if request.user.is_authenticated: -# user = User.objects.get(username=request.user.username) -# completed_questions = Question.objects.filter(profile=user.profile, attempt__passed_tests=True) -# valid_question_ids = [question.id for question in Question.objects.all() if question not in completed_questions] -# else: -# valid_question_ids = [question.id for question in Question.objects.all()] - -# if current_question_id in valid_question_ids: -# valid_question_ids.remove(current_question_id) - -# if len(valid_question_ids) < 1: -# url = '/' -# else: -# question_number = random.choice(valid_question_ids) -# url = '/questions/' + str(question_number) -# return redirect(url) - - -# def add_points(question, profile, passed_tests): -# """add appropriate number of points (if any) to user's account""" -# max_points_from_attempts = 3 -# points_for_correct = 10 - -# n_attempts = len(Attempt.objects.filter(question=question, profile=profile, is_save=False)) -# previous_corrects = Attempt.objects.filter(question=question, profile=profile, passed_tests=True, is_save=False) -# is_first_correct = len(previous_corrects) == 1 - -# points_to_add = 0 -# if n_attempts <= max_points_from_attempts: -# points_to_add += 1 - -# if passed_tests and is_first_correct: -# points_from_previous_attempts = n_attempts if n_attempts < max_points_from_attempts else max_points_from_attempts -# points_to_add += (points_for_correct - points_from_previous_attempts) - -# profile.points += points_to_add -# profile.full_clean() -# profile.save() - - -def save_question_attempt(request): - """Save user's attempt for a question. - - If the attempt is successful: add points if these haven't already - been added. - - Args: - request (Request): AJAX request from user. - - Returns: - JSON response with result. - """ - result = { - 'success': False, - } - if request.is_ajax(): - if request.user.is_authenticated: - request_json = json.loads(request.body.decode('utf-8')) - profile = request.user.profile - question = Question.objects.get(pk=request_json['question']) - user_code = request_json['user_input'] - - test_cases = request_json['test_cases'] - total_tests = len(test_cases) - total_passed = 0 - for test_case in test_cases.values(): - if test_case['passed']: - total_passed += 1 - - attempt = Attempt.objects.create( - profile=profile, - question=question, - user_code=user_code, - passed_tests=total_passed == total_tests, - ) - - # Create test case attempt objects - for test_case_id, test_case_data in test_cases.items(): - test_case = TestCase.objects.get(pk=test_case_id) - TestCaseAttempt.objects.create( - attempt=attempt, - test_case=test_case, - passed=test_case_data['passed'], - ) - - result['success'] = True - - return JsonResponse(result) - -# def save_goal_choice(request): -# """update user's goal choice in database""" -# request_json = json.loads(request.body.decode('utf-8')) -# if request.user.is_authenticated: -# user = User.objects.get(username=request.user.username) -# profile = user.profile - -# goal_choice = request_json['goal_choice'] -# profile.goal = int(goal_choice) -# profile.full_clean() -# profile.save() - -# return JsonResponse({}) - - -# def get_consecutive_sections(days_logged_in): -# """return a list of lists of consecutive days logged in""" -# consecutive_sections = [] - -# today = days_logged_in[0] -# previous_section = [today] -# for day in days_logged_in[1:]: -# if day == previous_section[-1] - datetime.timedelta(days=1): -# previous_section.append(day) -# else: -# consecutive_sections.append(previous_section) -# previous_section = [day] - -# consecutive_sections.append(previous_section) -# return consecutive_sections - - -# def check_badge_conditions(user): -# """check badges for account creation, days logged in, and questions solved""" -# earned_badges = user.profile.earned_badges.all() - -# # account creation badge -# try: -# creation_badge = Badge.objects.get(id_name="create-account") -# if creation_badge not in earned_badges: -# new_achievement = Earned(profile=user.profile, badge=creation_badge) -# new_achievement.full_clean() -# new_achievement.save() -# except (Badge.DoesNotExist): -# pass - -# # consecutive days logged in badges -# login_badges = Badge.objects.filter(id_name__contains="login") -# for login_badge in login_badges: -# if login_badge not in earned_badges: -# n_days = int(login_badge.id_name.split("-")[1]) - -# days_logged_in = LoginDay.objects.filter(profile=user.profile) -# days_logged_in = sorted(days_logged_in, key=lambda k: k.day, reverse=True) -# sections = get_consecutive_sections([d.day for d in days_logged_in]) - -# max_consecutive = len(max(sections, key=lambda k: len(k))) - -# if max_consecutive >= n_days: -# new_achievement = Earned(profile=user.profile, badge=login_badge) -# new_achievement.full_clean() -# new_achievement.save() - -# # solved questions badges -# solve_badges = Badge.objects.filter(id_name__contains="solve") -# for solve_badge in solve_badges: -# if solve_badge not in earned_badges: -# n_problems = int(solve_badge.id_name.split("-")[1]) -# n_completed = Attempt.objects.filter(profile=user.profile, passed_tests=True, is_save=False) -# n_distinct = n_completed.values("question__pk").distinct().count() -# if n_distinct >= n_problems: -# new_achievement = Earned(profile=user.profile, badge=solve_badge) -# new_achievement.full_clean() -# new_achievement.save() - - -# def get_past_5_weeks(user): -# """get how many questions a user has done each week for the last 5 weeks""" -# t = datetime.date.today() -# today = datetime.datetime(t.year, t.month, t.day) -# last_monday = today - datetime.timedelta(days=today.weekday(), weeks=0) -# last_last_monday = today - datetime.timedelta(days=today.weekday(), weeks=1) - -# past_5_weeks = [] -# to_date = today -# for week in range(0, 5): -# from_date = today - datetime.timedelta(days=today.weekday(), weeks=week) -# attempts = Attempt.objects.filter(profile=user.profile, date__range=(from_date, to_date + datetime.timedelta(days=1)), is_save=False) -# distinct_questions_attempted = attempts.values("question__pk").distinct().count() - -# label = str(week) + " weeks ago" -# if week == 0: -# label = "This week" -# elif week == 1: -# label = "Last week" - -# past_5_weeks.append({'week': from_date, 'n_attempts': distinct_questions_attempted, 'label': label}) -# to_date = from_date -# return past_5_weeks - - -class ProfileView(LoginRequiredMixin, generic.DetailView): - """Displays a user's profile.""" - - login_url = '/login/' - redirect_field_name = 'next' - template_name = 'codewof/profile.html' - model = Profile - - def get_object(self): - if self.request.user.is_authenticated: - return Profile.objects.get(user=self.request.user) - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - - # user = User.objects.get(username=self.request.user.username) - # questions = user.profile.attempted_questions.all() - - # check_badge_conditions(user) - - # context['goal'] = user.profile.goal - # context['all_badges'] = Badge.objects.all() - # context['past_5_weeks'] = get_past_5_weeks(user) - - # history = [] - # for question in questions: - # if question.title not in [question['title'] for question in history]: - # attempts = Attempt.objects.filter(profile=user.profile, question=question, is_save=False) - # if len(attempts) > 0: - # max_date = max(attempt.date for attempt in attempts) - # completed = any(attempt.passed_tests for attempt in attempts) - # history.append({'latest_attempt': max_date,'title': question.title,'n_attempts': len(attempts), 'completed': completed, 'id': question.pk}) - # context['history'] = sorted(history, key=lambda k: k['latest_attempt'], reverse=True) - return context - - -# class SkillView(LastAccessMixin, generic.DetailView): -# """displays list of questions which involve this skill""" -# template_name = 'codewof/skill.html' -# context_object_name = 'skill' -# model = SkillArea - -# def get_context_data(self, **kwargs): -# context = super().get_context_data(**kwargs) - -# skill = self.get_object() -# questions = skill.questions.all() -# context['questions'] = questions - -# if self.request.user.is_authenticated: -# user = User.objects.get(username=self.request.user.username) - -# history = [] -# for question in questions: -# if question.title not in [question['title'] for question in history]: -# attempts = Attempt.objects.filter(profile=user.profile, question=question, is_save=False) -# attempted = False -# completed = False -# if len(attempts) > 0: -# attempted = True -# completed = any(attempt.passed_tests for attempt in attempts) -# history.append( -# { -# 'attempted': attempted, -# 'completed': completed, -# 'title': question.title, -# 'id': question.pk -# } -# ) -# context['questions'] = history -# return context - - -class QuestionListView(generic.ListView): - """View for listing questions.""" - - model = Question - context_object_name = 'questions' - - def get_queryset(self): - """Return questions objects for page. - - Returns: - Question queryset. - """ - return Question.objects.all().select_subclasses() - - -class QuestionView(generic.base.TemplateView): - """Displays a question. - - This view requires to retrieve the object first in the context, - in order to determine the required template to render. - """ - - template_name = 'codewof/question.html' - - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - try: - self.question = Question.objects.get_subclass( - pk=self.kwargs['pk'] - ) - except Question.DoesNotExist: - raise Http404("No question matches the given ID.") - context['question'] = self.question - test_cases = self.question.test_cases.values() - context['test_cases'] = test_cases - context['test_cases_json'] = json.dumps(list(test_cases)) - context['question_js'] = QUESTION_JAVASCRIPT.format(self.question.QUESTION_TYPE) - - if self.request.user.is_authenticated: - try: - previous_attempt = Attempt.objects.filter( - profile=self.request.user.profile, - question=self.question, - ).latest('datetime') - except ObjectDoesNotExist: - previous_attempt = None - context['previous_attempt'] = previous_attempt - # all_attempts = Attempt.objects.filter(question=question, profile=profile) - # if len(all_attempts) > 0: - # context['previous_attempt'] = all_attempts.latest('date').user_code - return context diff --git a/codewof/programming/views.py b/codewof/programming/views.py index 6a97f5cb8..3c3e9f69e 100644 --- a/codewof/programming/views.py +++ b/codewof/programming/views.py @@ -1,6 +1,8 @@ """Views for programming application.""" import json +from django.conf import settings +from django.core import management from django.views import generic from django.utils import timezone from django.db.models import Count, Max @@ -24,19 +26,10 @@ ) from research.models import StudyRegistration -QUESTION_JAVASCRIPT = 'js/question_types/{}.js' - - -class IndexView(generic.base.TemplateView): - """Homepage for programming.""" - - template_name = 'programming/index.html' +from programming.codewof_utils import add_points, check_achievement_conditions - def get_context_data(self, **kwargs): - """Get additional context data for template.""" - context = super().get_context_data(**kwargs) - context['questions'] = Question.objects.select_subclasses() - return context +QUESTION_JAVASCRIPT = 'js/question_types/{}.js' +BATCH_SIZE = 15 class QuestionListView(LoginRequiredMixin, generic.ListView): @@ -184,6 +177,13 @@ def save_question_attempt(request): passed=test_case_data['passed'], ) result['success'] = True + points_before = profile.points + points = add_points(question, profile, attempt) + achievements = check_achievement_conditions(profile) + points_after = profile.points + result['curr_points'] = points + result['point_diff'] = points_after - points_before + result['achievements'] = achievements else: result['success'] = False result['message'] = 'Attempt not saved, same as previous attempt.' @@ -191,6 +191,23 @@ def save_question_attempt(request): return JsonResponse(result) +def partial_backdate(request): + """Backdate a set number of user profiles. + + Returns a 403 Forbidden response if the request was made to a live website and did not come from GCP. + """ + # https://cloud.google.com/appengine/docs/standard/python3/scheduling-jobs-with-cron-yaml?hl=en_US + # #validating_cron_requests + if settings.DEBUG or 'X-Appengine-Cron' in request.headers: + management.call_command("backdate_points_and_achievements", profiles=BATCH_SIZE) + response = { + 'success': True, + } + return JsonResponse(response) + else: + raise PermissionDenied() + + class CreateView(generic.base.TemplateView): """Page for creation programming questions.""" diff --git a/codewof/static/img/icons/achievements/icons8-achievement-create-account-48.png b/codewof/static/img/icons/achievements/icons8-achievement-create-account-48.png new file mode 100644 index 000000000..9fc2774e5 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-achievement-create-account-48.png differ diff --git a/codewof/static/img/icons/achievements/icons8-attempt-made-black-50.png b/codewof/static/img/icons/achievements/icons8-attempt-made-black-50.png new file mode 100644 index 000000000..074c96897 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-attempt-made-black-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-attempt-made-bronze-50.png b/codewof/static/img/icons/achievements/icons8-attempt-made-bronze-50.png new file mode 100644 index 000000000..0f831bac4 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-attempt-made-bronze-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-attempt-made-gold-50.png b/codewof/static/img/icons/achievements/icons8-attempt-made-gold-50.png new file mode 100644 index 000000000..17ceaa413 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-attempt-made-gold-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-attempt-made-silver-50.png b/codewof/static/img/icons/achievements/icons8-attempt-made-silver-50.png new file mode 100644 index 000000000..6a6b21f1c Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-attempt-made-silver-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-calendar-14-50.png b/codewof/static/img/icons/achievements/icons8-calendar-14-50.png new file mode 100644 index 000000000..b609e7afd Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-calendar-14-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-calendar-2-50.png b/codewof/static/img/icons/achievements/icons8-calendar-2-50.png new file mode 100644 index 000000000..79a690d87 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-calendar-2-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-calendar-21-50.png b/codewof/static/img/icons/achievements/icons8-calendar-21-50.png new file mode 100644 index 000000000..054b6c231 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-calendar-21-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-calendar-28-50.png b/codewof/static/img/icons/achievements/icons8-calendar-28-50.png new file mode 100644 index 000000000..e532ffa29 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-calendar-28-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-calendar-7-50.png b/codewof/static/img/icons/achievements/icons8-calendar-7-50.png new file mode 100644 index 000000000..ae1d27761 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-calendar-7-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-question-solved-black-50.png b/codewof/static/img/icons/achievements/icons8-question-solved-black-50.png new file mode 100644 index 000000000..ca2659f3f Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-question-solved-black-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-question-solved-bronze-50.png b/codewof/static/img/icons/achievements/icons8-question-solved-bronze-50.png new file mode 100644 index 000000000..a40520bd2 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-question-solved-bronze-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-question-solved-gold-50.png b/codewof/static/img/icons/achievements/icons8-question-solved-gold-50.png new file mode 100644 index 000000000..f7a726f60 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-question-solved-gold-50.png differ diff --git a/codewof/static/img/icons/achievements/icons8-question-solved-silver-50.png b/codewof/static/img/icons/achievements/icons8-question-solved-silver-50.png new file mode 100644 index 000000000..e3d1d5e34 Binary files /dev/null and b/codewof/static/img/icons/achievements/icons8-question-solved-silver-50.png differ diff --git a/codewof/static/img/icons/icons8-star-64.png b/codewof/static/img/icons/icons8-star-64.png new file mode 100644 index 000000000..2abeca3dc Binary files /dev/null and b/codewof/static/img/icons/icons8-star-64.png differ diff --git a/codewof/static/js/question_types/base.js b/codewof/static/js/question_types/base.js index 50bc83d22..98e2460b0 100644 --- a/codewof/static/js/question_types/base.js +++ b/codewof/static/js/question_types/base.js @@ -1,4 +1,7 @@ +$ = jQuery = require('jquery'); require('skulpt'); +require('bootstrap'); +require('details-element-polyfill'); function ajax_request(url_name, data, success_function) { $.ajax({ @@ -9,7 +12,7 @@ function ajax_request(url_name, data, success_function) { contentType: 'application/json; charset=utf-8', headers: { "X-CSRFToken": csrf_token }, dataType: 'json', - success: success_function + success: update_gamification }); } @@ -25,6 +28,31 @@ function clear_submission_feedback() { $('#submission_feedback').empty(); } +function update_gamification(data) { + curr_points = data.curr_points; + $('#user_points_navbar').innerText = curr_points; + $("#user_points_navbar").load(location.href + " #user_points_navbar"); // Add space between URL and selector. + + point_diff = parseInt(data.point_diff); + if(point_diff > 0) { + $("#point_toast_header").text("Points earned!"); + $("#point_toast_body").text("You earned " + point_diff.toString() +" points!"); + $(document).ready(function(){ + $("#point_toast").toast('show', {delay: 5000}); + }); + } + + achievements = data.achievements; + if (achievements.length > 0){ + $("#achievement_toast_header").text("New achievements!"); + $("#achievement_toast_body").text(achievements); + $(document).ready(function(){ + $("#achievement_toast").toast('show', {delay: 5000}); + }); + } + +} + function display_submission_feedback(test_cases) { var container = $('#submission_feedback'); var total_tests = Object.keys(test_cases).length; @@ -43,7 +71,7 @@ function display_submission_feedback(test_cases) { text = 'Great work! All the tests passed.'; container.append(create_alert('success', text)); } else { - text = 'Oh no! It seems like some of the tests failed. Try to figure out why, and then try again.'; + text = 'Oh no! It seems like some of the tests did not pass. Try to figure out why, and then try again.'; container.append(create_alert('danger', text)); } } diff --git a/codewof/static/scss/_homepage.scss b/codewof/static/scss/_homepage.scss index f60a7871e..f58f28bff 100644 --- a/codewof/static/scss/_homepage.scss +++ b/codewof/static/scss/_homepage.scss @@ -10,7 +10,7 @@ margin-bottom: 1rem; } - .badge { + .achievement { font-size: 1rem; } diff --git a/codewof/static/scss/_question-card.scss b/codewof/static/scss/_question-card.scss index 73d55eb26..d143a5070 100644 --- a/codewof/static/scss/_question-card.scss +++ b/codewof/static/scss/_question-card.scss @@ -22,8 +22,8 @@ .qc-card { display: grid; grid-template-areas: - "qc-checkbox qc-type qc-icon" - "qc-checkbox qc-title qc-icon"; + "qc-checkbox qc-type" + "qc-checkbox qc-title"; grid-template-columns: auto 1fr auto; grid-template-rows: auto auto; border: 3px solid grey; @@ -56,10 +56,6 @@ margin-right: 0.5rem; } -.qc-icon { - grid-area: qc-icon; -} - .qc-type { align-self: end; grid-area: qc-type; diff --git a/codewof/static/scss/website.scss b/codewof/static/scss/website.scss index 343b53f16..376c7e2fe 100644 --- a/codewof/static/scss/website.scss +++ b/codewof/static/scss/website.scss @@ -171,13 +171,33 @@ strong { max-height: 5rem; } -// Create badge CSS rules off colour names +// Create achievement CSS rules off colour names @each $color, $value in $colors { .badge-#{$color} { @include badge-variant($value); } } +.achievement-container { + clear: both; + padding-bottom: 0.5rem; +} + +.achievement-icon { + padding: 0.3125rem; + float: left; +} + +.achievement-icon-unachieved { + padding: 0.3125rem; + opacity: 0.2; +} + +.achievements-link, +.achievements-link:hover { + color: inherit; +} + .img-inline { max-height: 1.5rem; } @@ -248,3 +268,34 @@ $red: #b94a48; .orange-underline { border-bottom: 0.5rem $brand-secondary-colour solid; } + +#toast-container { + position: fixed; + top: 3rem; + right: 2rem; +} + +.toast { + border: 1px solid $brand-colour; + background-color: brand-colour-light; + text-align: center; +} + +.toast-header { + border: 1px solid $brand-colour; + background-color: brand-colour-light; + width: 100%; + font-weight: bold; + display: inline-block; +} + +.toast-header strong { + color: black; + font-weight: bold; +} + +.toast-body { + border: 1px solid $brand-colour; + background-color: brand-colour-light; + white-space: pre; +} diff --git a/codewof/static/svg/icons8-points.svg b/codewof/static/svg/icons8-points.svg new file mode 100644 index 000000000..7179cd874 --- /dev/null +++ b/codewof/static/svg/icons8-points.svg @@ -0,0 +1 @@ + diff --git a/codewof/templates/base.html b/codewof/templates/base.html index 19db2b69b..2f956192b 100644 --- a/codewof/templates/base.html +++ b/codewof/templates/base.html @@ -34,8 +34,8 @@ - {% block body_element %}{% endblock body_element %} -