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/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/general/management/commands/sampledata.py b/codewof/general/management/commands/sampledata.py index 99398924c..f70f10681 100644 --- a/codewof/general/management/commands/sampledata.py +++ b/codewof/general/management/commands/sampledata.py @@ -50,7 +50,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 +67,25 @@ 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_badges') + print('Achievement badges 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 badges + management.call_command('backdate_points_and_badges') diff --git a/codewof/programming/admin.py b/codewof/programming/admin.py index 8733f527e..da3560d50 100644 --- a/codewof/programming/admin.py +++ b/codewof/programming/admin.py @@ -8,7 +8,10 @@ QuestionTypeProgram, QuestionTypeFunction, QuestionTypeParsons, - QuestionTypeDebugging + QuestionTypeDebugging, + Profile, + Badge, + Earned, ) User = get_user_model() @@ -23,6 +26,37 @@ class TestCaseAttemptInline(admin.TabularInline): can_delete = False +class EarnedInline(admin.TabularInline): + """Configuration to show earned badges inline within profile admin.""" + + model = Earned + extra = 1 + + +class ProfileAdmin(admin.ModelAdmin): + """Configuration for displaying profiles in admin.""" + + list_display = ('user', 'points', 'goal') + ordering = ('user', ) + inlines = (EarnedInline, ) + + +class BadgeAdmin(admin.ModelAdmin): + """Configuration for displaying badges in admin.""" + + list_display = ('id_name', 'display_name', 'badge_tier') + list_filter = ['badge_tier'] + ordering = ('id_name', ) + + +class EarnedAdmin(admin.ModelAdmin): + """Configuration for displaying earned badges in admin.""" + + list_display = ('date', 'badge', 'profile') + list_filter = ['badge'] + ordering = ('-date', ) + + class AttemptAdmin(admin.ModelAdmin): """Configuration for displaying attempts in admin.""" @@ -43,3 +77,6 @@ class Media: admin.site.register(QuestionTypeFunction) admin.site.register(QuestionTypeParsons) admin.site.register(QuestionTypeDebugging) +admin.site.register(Profile, ProfileAdmin) +admin.site.register(Badge, BadgeAdmin) +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..1acfc83d2 --- /dev/null +++ b/codewof/programming/codewof_utils.py @@ -0,0 +1,249 @@ +"""Utility functions for codeWOF system. Involves points, badges, and backdating points and badges per user.""" + +import datetime +import json +import logging +from dateutil.relativedelta import relativedelta + +from programming.models import ( + Profile, + Question, + Attempt, + Badge, + Earned, +) +from django.http import JsonResponse +# from django.conf import settings + +# time_zone = settings.TIME_ZONE + +logger = logging.getLogger(__name__) +del logging + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'incremental': True, + 'root': { + 'level': 'DEBUG', + }, +} + +# Number of points awarded for achieving each goal +POINTS_BADGE = 10 +POINTS_SOLUTION = 10 +POINTS_BONUS = 2 + + +def add_points(question, profile, attempt): + """ + Add appropriate number of points (if any) to user's 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. If the user + answers the question correctly the first time they answer, the user gains bonus points. + + Subsequent correct answers should not award any points. + """ + num_attempts = Attempt.objects.filter(question=question, profile=profile) + is_first_correct = len(Attempt.objects.filter(question=question, profile=profile, passed_tests=True)) == 1 + logger.warning(num_attempts) + logger.warning(is_first_correct) + points_to_add = 0 + + # check if first passed + if attempt.passed_tests and is_first_correct: + points_to_add += POINTS_SOLUTION + if len(num_attempts) == 1: + # correct first try + points_to_add += POINTS_BONUS + + profile.points += points_to_add + 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(user): + """ + Get the number of consecutive days with questions attempted. + + Gets all datetimes of attempts for the given user's profile, and checks for the longest continuous "streak" of + days where attempts were made. Returns an integer of the longest attempt "streak". + """ + # get datetimes from attempts in date form) + attempts = Attempt.objects.filter(profile=user.profile).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(user): + """Get the number questions successfully answered in the past month.""" + today = datetime.datetime.now().replace(tzinfo=None) + relativedelta(days=1) + last_month = today - relativedelta(months=1) + solved = Attempt.objects.filter(profile=user.profile, datetime__gte=last_month.date(), passed_tests=True) + return len(solved) + + +def check_badge_conditions(user): + """ + Check if the user has earned new badges for their profile. + + Checks if the user has received each available badge. If not, check if the user has earned these badges. Badges + available to be checked for are profile creation, number of attempts made, number of questions answered, and + number of days with consecutive attempts. + + A badge will not be removed if the user had earned it before but now doesn't meet the conditions + """ + earned_badges = user.profile.earned_badges.all() + new_badge_names = "" + new_badge_objects = [] + # account creation badge + try: + creation_badge = Badge.objects.get(id_name="create-account") + if creation_badge not in earned_badges: + # create a new account creation + new_achievement = Earned(profile=user.profile, badge=creation_badge) + new_achievement.full_clean() + new_achievement.save() + new_badge_names = new_badge_names + "- " + creation_badge.display_name + "\n" + new_badge_objects.append(creation_badge) + except Badge.DoesNotExist: + logger.warning("No such badge: create-account") + pass + + # check questions solved badges + try: + question_badges = Badge.objects.filter(id_name__contains="questions-solved") + solved = Attempt.objects.filter(profile=user.profile, passed_tests=True) + for question_badge in question_badges: + if question_badge not in earned_badges: + num_questions = int(question_badge.id_name.split("-")[2]) + if len(solved) >= num_questions: + new_achievement = Earned(profile=user.profile, badge=question_badge) + new_achievement.full_clean() + new_achievement.save() + new_badge_names = new_badge_names + "- " + question_badge.display_name + "\n" + new_badge_objects.append(question_badge) + except Badge.DoesNotExist: + logger.warning("No such badges: questions-solved") + pass + + # checked questions attempted badges + try: + attempt_badges = Badge.objects.filter(id_name__contains="attempts-made") + attempted = Attempt.objects.filter(profile=user.profile) + for attempt_badge in attempt_badges: + if attempt_badge not in earned_badges: + num_questions = int(attempt_badge.id_name.split("-")[2]) + if len(attempted) >= num_questions: + new_achievement = Earned(profile=user.profile, badge=attempt_badge) + new_achievement.full_clean() + new_achievement.save() + new_badge_names = new_badge_names + "- " + attempt_badge.display_name + "\n" + new_badge_objects.append(attempt_badge) + except Badge.DoesNotExist: + logger.warning("No such badges: attempts-made") + pass + + # consecutive days logged in badges + num_consec_days = get_days_consecutively_answered(user) + consec_badges = Badge.objects.filter(id_name__contains="consecutive-days") + for consec_badge in consec_badges: + if consec_badge not in earned_badges: + n_days = int(consec_badge.id_name.split("-")[2]) + if n_days <= num_consec_days: + new_achievement = Earned(profile=user.profile, badge=consec_badge) + new_achievement.full_clean() + new_achievement.save() + new_badge_names = new_badge_names + "- " + consec_badge.display_name + "\n" + new_badge_objects.append(consec_badge) + + calculate_badge_points(user, new_badge_objects) + return new_badge_names + + +def calculate_badge_points(user, badges): + """Calculate points earned by the user for new badges earned by multiplying the badge tier by 10.""" + for badge in badges: + points = badge.badge_tier * POINTS_BADGE + user.profile.points += points + user.full_clean() + user.save() + + +def backdate_points_and_badges(): + """Perform backdate of all points and badges for each profile in the system.""" + profiles = Profile.objects.all() + num_profiles = len(profiles) + for i in range(num_profiles): + print("Backdating users: " + str(i + 1) + "/" + str(num_profiles), end="\r") + profile = profiles[i] + profile = backdate_badges(profile) + profile = backdate_points(profile) + # save profile when update is completed + profile.full_clean() + profile.save() + print("\nBackdate complete.") + + +def backdate_points(profile): + """Re-calculate points for the user profile.""" + questions = Question.objects.all() + profile.points = 0 + for question in questions: + has_passed = len(Attempt.objects.filter(profile=profile, question=question, passed_tests=True)) > 0 + user_attempts = Attempt.objects.filter(profile=profile, question=question) + first_passed = False + if len(user_attempts) > 0: + first_passed = user_attempts[0].passed_tests + if has_passed: + profile.points += POINTS_SOLUTION + if first_passed: + profile.points += POINTS_BONUS + for badge in profile.earned_badges.all(): + profile.points += POINTS_BADGE * badge.badge_tier + return profile + + +def backdate_badges(profile): + """Re-check the profile for badges earned.""" + check_badge_conditions(profile.user) + return profile diff --git a/codewof/programming/management/commands/backdate_points_and_badges.py b/codewof/programming/management/commands/backdate_points_and_badges.py new file mode 100644 index 000000000..d99e6d058 --- /dev/null +++ b/codewof/programming/management/commands/backdate_points_and_badges.py @@ -0,0 +1,14 @@ +"""Module for the custom Django backdate_points_and_badges command.""" + +from django.core.management.base import BaseCommand +from programming.codewof_utils import backdate_points_and_badges + + +class Command(BaseCommand): + """Required command class for the custom Django backdate command.""" + + help = 'Loads questions into the database' + + def handle(self, *args, **options): + """Automatically called when the backdate command is given.""" + backdate_points_and_badges() diff --git a/codewof/programming/management/commands/load_badges.py b/codewof/programming/management/commands/load_badges.py new file mode 100644 index 000000000..f092479e3 --- /dev/null +++ b/codewof/programming/management/commands/load_badges.py @@ -0,0 +1,148 @@ +"""Module for the custom Django load_badges command.""" + +from django.core.management.base import BaseCommand +from programming.models import Badge + +# TODO: Relocate +BADGES = [ + { + 'id_name': 'create-account', + 'display_name': 'Created an account!', + 'description': 'Created your very own account', + 'icon_name': 'img/icons/badges/icons8-badge-create-account-48.png', + 'badge_tier': 0, + 'parent': None + }, + { + 'id_name': 'questions-solved-100', + 'display_name': 'Solved one hundred questions!', + 'description': 'Solved one hundred questions', + 'icon_name': 'img/icons/badges/icons8-question-solved-gold-50.png', + 'badge_tier': 4, + 'parent': None + }, + { + 'id_name': 'questions-solved-10', + 'display_name': 'Solved ten questions!', + 'description': 'Solved ten questions', + 'icon_name': 'img/icons/badges/icons8-question-solved-silver-50.png', + 'badge_tier': 3, + 'parent': 'questions-solved-100' + }, + { + 'id_name': 'questions-solved-5', + 'display_name': 'Solved five questions!', + 'description': 'Solved five questions', + 'icon_name': 'img/icons/badges/icons8-question-solved-bronze-50.png', + 'badge_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/badges/icons8-question-solved-black-50.png', + 'badge_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/badges/icons8-attempt-made-gold-50.png', + 'badge_tier': 4, + 'parent': None + }, + { + 'id_name': 'attempts-made-10', + 'display_name': 'Made ten question attempts!', + 'description': 'Attempted ten questions', + 'icon_name': 'img/icons/badges/icons8-attempt-made-silver-50.png', + 'badge_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/badges/icons8-attempt-made-bronze-50.png', + 'badge_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/badges/icons8-attempt-made-black-50.png', + 'badge_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/badges/icons8-calendar-28-50.png', + 'badge_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/badges/icons8-calendar-21-50.png', + 'badge_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/badges/icons8-calendar-14-50.png', + 'badge_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/badges/icons8-calendar-7-50.png', + 'badge_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/badges/icons8-calendar-2-50.png', + 'badge_tier': 1, + 'parent': 'consecutive-days-7' + }, +] + + +class Command(BaseCommand): + """Required command class for the custom Django load_badges command. + + Future plan: Create full loader like the load_questions command + """ + + help = 'Loads badges into the database' + + def handle(self, *args, **options): + """Automatically called when the load_badges command is given.""" + all_badges = {} + + for badge in BADGES: + all_badges[badge['id_name']], created = Badge.objects.update_or_create( + id_name=badge['id_name'], + defaults={ + 'display_name': badge['display_name'], + 'description': badge['description'], + 'icon_name': badge['icon_name'], + 'badge_tier': badge['badge_tier'], + 'parent': None if badge['parent'] is None else all_badges[badge['parent']] + } + ) + print("{} badge: {}".format("Created" if created else "Updated", badge['id_name'])) + + print("{} badges loaded!\n".format(len(all_badges))) 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/models.py b/codewof/programming/models.py index 3f8a575cd..c5af929d9 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,8 @@ 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_badges = models.ManyToManyField('Badge', through='Earned') + attempted_questions = models.ManyToManyField('Question', through='Attempt') def __str__(self): """Text representation of a profile.""" @@ -47,41 +48,52 @@ 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 Badge(models.Model): + """Badge 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) + badge_tier = models.IntegerField(default=0) + parent = models.ForeignKey('Badge', on_delete=models.CASCADE, null=True, default=None) + def __str__(self): + """Text representation of a badge.""" + 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) -# def __str__(self): -# return self.display_name +class Earned(models.Model): + """Model that documents when a badge is earned by a user in their profile.""" + profile = models.ForeignKey('Profile', on_delete=models.CASCADE) + badge = models.ForeignKey('Badge', on_delete=models.CASCADE) + date = models.DateTimeField(default=timezone.now) -# 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 Meta: + """How the name is displayed in the Admin view.""" -# def __str__(self): -# return str(self.date) + verbose_name = "Earned badge" + verbose_name_plural = "Badges earned" -# class Token(models.Model): -# name = models.CharField(max_length=SMALL, primary_key=True) -# token = models.CharField(max_length=LARGE) + def __str__(self): + """Text representation of an Earned object.""" + return str(self.date) -# def __str__(self): -# return self.name + +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 +107,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 +160,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..7ee9a611d 100644 --- a/codewof/programming/urls.py +++ b/codewof/programming/urls.py @@ -16,7 +16,6 @@ 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'), 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..0ce2d6bf1 100644 --- a/codewof/programming/views.py +++ b/codewof/programming/views.py @@ -24,19 +24,9 @@ ) 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_badge_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' class QuestionListView(LoginRequiredMixin, generic.ListView): @@ -184,6 +174,13 @@ def save_question_attempt(request): passed=test_case_data['passed'], ) result['success'] = True + points_before = profile.points + points = add_points(question, profile, attempt) + badges = check_badge_conditions(profile.user) + points_after = profile.points + result['curr_points'] = points + result['point_diff'] = points_after - points_before + result['badges'] = badges else: result['success'] = False result['message'] = 'Attempt not saved, same as previous attempt.' @@ -218,6 +215,26 @@ def get_context_data(self, **kwargs): return context +# class ProfileView(LoginRequiredMixin, generic.DetailView): +# """Displays a user's profile.""" + +# login_url = '/login/' +# redirect_field_name = 'next' +# template_name = 'users/user_detail.html' +# model = Profile + +# def get_context_data(self, **kwargs): +# """Get additional context data for template.""" +# context = super().get_context_data(**kwargs) + +# user = self.request.user +# context['goal'] = user.profile.goal +# context['all_badges'] = Badge.objects.all() +# check_badge_conditions(user) +# # context['past_5_weeks'] = get_past_5_weeks(user) +# return context + + class QuestionAPIViewSet(viewsets.ReadOnlyModelViewSet): """API endpoint that allows questions to be viewed.""" diff --git a/codewof/static/img/icons/badges/icons8-attempt-made-black-50.png b/codewof/static/img/icons/badges/icons8-attempt-made-black-50.png new file mode 100644 index 000000000..074c96897 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-attempt-made-black-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-attempt-made-bronze-50.png b/codewof/static/img/icons/badges/icons8-attempt-made-bronze-50.png new file mode 100644 index 000000000..0f831bac4 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-attempt-made-bronze-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-attempt-made-gold-50.png b/codewof/static/img/icons/badges/icons8-attempt-made-gold-50.png new file mode 100644 index 000000000..17ceaa413 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-attempt-made-gold-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-attempt-made-silver-50.png b/codewof/static/img/icons/badges/icons8-attempt-made-silver-50.png new file mode 100644 index 000000000..6a6b21f1c Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-attempt-made-silver-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-badge-create-account-48.png b/codewof/static/img/icons/badges/icons8-badge-create-account-48.png new file mode 100644 index 000000000..9fc2774e5 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-badge-create-account-48.png differ diff --git a/codewof/static/img/icons/badges/icons8-calendar-14-50.png b/codewof/static/img/icons/badges/icons8-calendar-14-50.png new file mode 100644 index 000000000..b609e7afd Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-calendar-14-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-calendar-2-50.png b/codewof/static/img/icons/badges/icons8-calendar-2-50.png new file mode 100644 index 000000000..79a690d87 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-calendar-2-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-calendar-21-50.png b/codewof/static/img/icons/badges/icons8-calendar-21-50.png new file mode 100644 index 000000000..054b6c231 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-calendar-21-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-calendar-28-50.png b/codewof/static/img/icons/badges/icons8-calendar-28-50.png new file mode 100644 index 000000000..e532ffa29 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-calendar-28-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-calendar-7-50.png b/codewof/static/img/icons/badges/icons8-calendar-7-50.png new file mode 100644 index 000000000..ae1d27761 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-calendar-7-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-question-solved-black-50.png b/codewof/static/img/icons/badges/icons8-question-solved-black-50.png new file mode 100644 index 000000000..ca2659f3f Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-question-solved-black-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-question-solved-bronze-50.png b/codewof/static/img/icons/badges/icons8-question-solved-bronze-50.png new file mode 100644 index 000000000..a40520bd2 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-question-solved-bronze-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-question-solved-gold-50.png b/codewof/static/img/icons/badges/icons8-question-solved-gold-50.png new file mode 100644 index 000000000..f7a726f60 Binary files /dev/null and b/codewof/static/img/icons/badges/icons8-question-solved-gold-50.png differ diff --git a/codewof/static/img/icons/badges/icons8-question-solved-silver-50.png b/codewof/static/img/icons/badges/icons8-question-solved-silver-50.png new file mode 100644 index 000000000..e3d1d5e34 Binary files /dev/null and b/codewof/static/img/icons/badges/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..304d50c1d 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: 3000}); + }); + } + + badges = data.badges; + if (badges.length > 0){ + $("#badge_toast_header").text("New badges!"); + $("#badge_toast_body").text(badges); + $(document).ready(function(){ + $("#badge_toast").toast('show', {delay: 3000}); + }); + } + +} + 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/website.scss b/codewof/static/scss/website.scss index 343b53f16..1aaa4b8e8 100644 --- a/codewof/static/scss/website.scss +++ b/codewof/static/scss/website.scss @@ -178,6 +178,26 @@ strong { } } +.badge-container { + clear: both; + padding-bottom: 0.5rem; +} + +.badge-icon { + padding: 0.3125rem; + float: left; +} + +.badge-icon-unachieved { + padding: 0.3125rem; + opacity: 0.2; +} + +.achievements-link, +.achievements-link:hover { + color: inherit; +} + .img-inline { max-height: 1.5rem; } @@ -248,3 +268,33 @@ $red: #b94a48; .orange-underline { border-bottom: 0.5rem $brand-secondary-colour solid; } + +#toast-container { + position: absolute; + top: 0; + right: 0; +} + +.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; +} diff --git a/codewof/static/svg/icons8-points.svg b/codewof/static/svg/icons8-points.svg new file mode 100644 index 000000000..a6d33102c --- /dev/null +++ b/codewof/static/svg/icons8-points.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codewof/templates/base.html b/codewof/templates/base.html index 19db2b69b..1feb5dabd 100644 --- a/codewof/templates/base.html +++ b/codewof/templates/base.html @@ -52,11 +52,14 @@ {% endif %} +

You've solved {{num_questions_answered}} question{{ num_questions_answered|pluralize }} in the last month!

-

Achievements

-

Coming soon!

+

Points earned: {{user.profile.points}}

+

Achievements

+
+ {% for badge in all_badges %} + {% if badge in user.profile.earned_badges.all and badge.parent not in user.profile.earned_badges.all %} +
+ + + {{badge.display_name}} + +
+ {% endif %} + {% endfor %}
diff --git a/codewof/templates/users/user_detail.html b/codewof/templates/users/user_detail.html new file mode 100644 index 000000000..14941231c --- /dev/null +++ b/codewof/templates/users/user_detail.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}User: {{ user.get_full_name }}{% endblock %} + +{% block content %} +
+ +
+
+

{{ user.get_full_name }}

+ +

Date joined: {{ user.date_joined }}

+

Points earned: {{user.profile.points}}

+ Achievements: +
+ {% for badge in all_badges %} + {% if badge in user.profile.earned_badges.all %} + + {{badge.display_name}} + {% else %} + {{badge.display_name}} + {% endif %} +
+ {% endfor %} +
+
+ +
+
+

Profile Settings

+ +
+
+
+{% endblock content %} diff --git a/codewof/tests/codewof_test_data_generator.py b/codewof/tests/codewof_test_data_generator.py new file mode 100644 index 000000000..a16e8c89f --- /dev/null +++ b/codewof/tests/codewof_test_data_generator.py @@ -0,0 +1,133 @@ +"""Class to generate test data required for testing codewof system.""" + +from django.contrib.auth import get_user_model +from django.core import management +import datetime + +from programming.models import ( + Question, + Attempt, + Badge, + QuestionTypeProgram, + QuestionTypeFunction, + QuestionTypeParsons, + QuestionTypeDebugging, +) + +from users.models import UserType + +User = get_user_model() + + +def generate_questions(): + """Generate questions for use in codeWOF tests. Questions contain minimum information and complexity.""" + Question.objects.create(slug="question-1", title='Test', question_text='Hello') + + QuestionTypeProgram.objects.create( + slug="program-question-1", + title='Test', + question_text='Hello', + solution="question_answer" + ) + + QuestionTypeFunction.objects.create( + slug="function-question-1", + title='Test', + question_text='Hello', + solution="question_answer" + ) + + QuestionTypeParsons.objects.create( + slug="parsons-question-1", + title='Test', + question_text='Hello', + solution="question_answer", + lines="These are\nthe lines" + ) + + QuestionTypeDebugging.objects.create( + slug="debugging-question-1", + title='Test', + question_text='Hello', + solution="question_answer", + initial_code='' + ) + + +def generate_users(user): + """Generate users for codeWOF tests. Creates two basic users for unit tests.""" + management.call_command("load_user_types") + user_john = User.objects.create_user( + id=1, + username='john', + first_name='John', + last_name='Doe', + email='john@uclive.ac.nz', + password='onion', + user_type=UserType.objects.get(slug='student') + ) + user_john.save() + + user_sally = User.objects.create_user( + id=2, + username='sally', + first_name='Sally', + last_name='Jones', + email='sally@uclive.ac.nz', + password='onion', + user_type=UserType.objects.get(slug='other') + ) + user_sally.save() + + +def generate_badges(): + """Create badges for codeWOF tests. Badges created for each main current badge category.""" + Badge.objects.create( + id_name='questions-solved-1', + display_name='Solved one question', + description='first', + badge_tier=1, + ) + Badge.objects.create( + id_name='create-account', + display_name='Account created', + description='test', + badge_tier=0, + ) + Badge.objects.create( + id_name='attempts-made-5', + display_name='Five attempts made', + description='test', + badge_tier=2 + ) + Badge.objects.create( + id_name='attempts-made-1', + display_name='One attempt made', + description='test', + badge_tier=1, + parent=Badge.objects.get(id_name='attempts-made-5') + ) + Badge.objects.create( + id_name='consecutive-days-2', + display_name='Two consecutive days', + description='test', + badge_tier=1, + ) + + +def generate_attempts(): + """ + Generate attempts for codeWOF tests. + + Attempts are generated for user 1 and question 1, with attempts created to cover consecutive days, failed attempts, + and passed attempts. These attempts cover the main requirements to gain all test badges. + """ + user = User.objects.get(id=1) + question = Question.objects.get(slug='question-1') + Attempt.objects.create(profile=user.profile, question=question, passed_tests=True) + Attempt.objects.create(profile=user.profile, question=question, passed_tests=False) + Attempt.objects.create(profile=user.profile, question=question, passed_tests=False) + Attempt.objects.create(profile=user.profile, question=question, passed_tests=True, + datetime=datetime.date(2019, 9, 9)) + Attempt.objects.create(profile=user.profile, question=question, passed_tests=True, + datetime=datetime.date(2019, 9, 10)) diff --git a/codewof/tests/features/badges.feature b/codewof/tests/features/badges.feature new file mode 100644 index 000000000..71fa92bd2 --- /dev/null +++ b/codewof/tests/features/badges.feature @@ -0,0 +1,27 @@ +Feature: Badges + +#check badge on creation +Scenario: User profile is created + Given a user with ID 1 exists + When their account is created + Then their profile has 1 badge + +Scenario: User profile earns badge for one attempt + Given a user with ID 1 exists + And they have not earned the badge for one attempt + When the user attempts the "Print CodeWOF" question without solving it + Then the user earns a badge for one attempt made + +Scenario: User profile earns badge for one question solved + Given a user with ID 1 exists + And they have not earned the badge for solving one question + When the user solves the "Print CodeWOF" question + Then the user earns a badge for one question solved + +Scenario: User profile earns two badges on first question + Given a user with ID 1 exists + And they have not earned the badge for solving one question + And they have not attempted the "Print CodeWOF" question + And they have not earned the badge for one attempt + When the user solves the "Print CodeWOF" question + Then the user earns the one question solved badge and the one attempt made badge diff --git a/codewof/tests/features/points.feature b/codewof/tests/features/points.feature new file mode 100644 index 000000000..8d2bea989 --- /dev/null +++ b/codewof/tests/features/points.feature @@ -0,0 +1,55 @@ +Feature: Points + +#Test initial points are zero +Scenario: User profile is created + Given a user with ID 1 is logged in + When their account is created + Then their profile's points are equal to 0 + +#Test bonus point for first correct login +Scenario: User solves a question on first attempt + Given a user with ID 1 is logged in + And their profile points are 0 + And they have not attempted the "Print CodeWOF" question + When they solve the "Print CodeWOF" question + Then the user's points equal 12 + +#Test for question that has previously been attempted +Scenario: User solves an attempted question + Given a user with ID 1 is logged in + And their profile points are 0 + And they have attempted the "Print CodeWOF" question + When they solve the "Print CodeWOF" question + Then the user's points equal 10 + +#Test for question that has been attempted but not solved +Scenario: User attempts a question + Given a user with ID 1 is logged in + And their profile points are 0 + When they attempt the "Print CodeWOF" question without solving it + Then the user's points equal 0 + +#Test for question that has previously been solved +Scenario: User attempts a question + Given a user with ID 1 is logged in + And their profile points are 10 + And they have already solved the "Print CodeWOF" question + When they solve the "Print CodeWOF" question + Then the user's points equal 10 + +#Test for points earned with first attempt badge +Scenario: User earns badge for one attempt + Given a user with ID 1 is logged in + And their profile points are 0 + And they have not earned the badge for attempting one question + When they attempt the "Print CodeWOF" question + Then the user's points equal 10 + + #Test for points earned with five attempts badge + Scenario: User earns badge for five attempts + Given a user with ID 1 is logged in + And their profile points are 10 + And they have not earned the badge for attempting five questions + And they have made 4 attempts in total + When they attempt the "Print CodeWOF" question + Then the user's points equal 30 diff --git a/codewof/tests/programming/factories.py b/codewof/tests/programming/factories.py index b942ec40a..e103fcc09 100644 --- a/codewof/tests/programming/factories.py +++ b/codewof/tests/programming/factories.py @@ -8,6 +8,7 @@ post_generation, ) from programming.models import Question, Profile, Attempt +from django.utils import timezone # shuffle the quesitons so it doesn't appear as 1, 2, 3, 4... question_list = list(Question.objects.all()) @@ -18,7 +19,7 @@ class AttemptFactory(DjangoModelFactory): """Factory for generating attempts.""" profile = Iterator(Profile.objects.all()) - datetime = Faker('iso8601') + datetime = Faker('date_time', tzinfo=timezone.get_current_timezone()) question = Iterator(question_list) user_code = Faker('paragraph', nb_sentences=5) diff --git a/codewof/tests/programming/test_codewof_utils.py b/codewof/tests/programming/test_codewof_utils.py new file mode 100644 index 000000000..42935dc74 --- /dev/null +++ b/codewof/tests/programming/test_codewof_utils.py @@ -0,0 +1,169 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model + +from programming.models import ( + Question, + Attempt, + Badge, + Earned, +) +from codewof.tests.codewof_test_data_generator import ( + generate_users, + generate_badges, + generate_questions, + generate_attempts, +) +from programming.codewof_utils import ( + add_points, + backdate_points, + backdate_points_and_badges, + calculate_badge_points, + check_badge_conditions, + get_days_consecutively_answered, + get_questions_answered_in_past_month, + POINTS_BADGE, + POINTS_SOLUTION, + POINTS_BONUS, +) +from codewof.tests.conftest import user + +User = get_user_model() + + +class TestCodewofUtils(TestCase): + @classmethod + def setUpTestData(cls): + # never modify this object in tests - read only + generate_users(user) + generate_questions() + generate_badges() + + def test_add_points_first_attempt_correct(self): + user = User.objects.get(id=1) + question = Question.objects.get(id=1) + attempt = Attempt.objects.create( + profile=user.profile, + question=question, + passed_tests=True + ) + points_before = user.profile.points + points_after = add_points(question, user.profile, attempt) + self.assertEqual(points_after - points_before, POINTS_SOLUTION + POINTS_BONUS) + + def test_add_points_first_attempt_incorrect(self): + user = User.objects.get(id=1) + question = Question.objects.get(id=1) + attempt_1 = Attempt.objects.create( + profile=user.profile, + question=question, + passed_tests=False + ) + points_before = user.profile.points + points_after = add_points(question, user.profile, attempt_1) + self.assertEqual(points_after - points_before, 0) + + attempt_2 = Attempt.objects.create( + profile=user.profile, + question=question, + passed_tests=True + ) + points_before = user.profile.points + points_after = add_points(question, user.profile, attempt_2) + self.assertEqual(points_after - points_before, POINTS_SOLUTION) + + def test_calculate_badge_points_tier_0(self): + user = User.objects.get(id=1) + badge = Badge.objects.get(id_name="create-account") + badges = [badge] + + points_before = user.profile.points + calculate_badge_points(user, badges) + self.assertEqual(user.profile.points - points_before, badge.badge_tier * POINTS_BADGE) + + def test_calculate_badge_points_tier_1(self): + user = User.objects.get(id=1) + badge = Badge.objects.get(id_name="questions-solved-1") + badges = [badge] + + points_before = user.profile.points + calculate_badge_points(user, badges) + self.assertEqual(user.profile.points - points_before, badge.badge_tier * POINTS_BADGE) + + def test_calculate_badge_points_tier_2(self): + user = User.objects.get(id=1) + badge = Badge.objects.get(id_name="attempts-made-5") + badges = [badge] + + points_before = user.profile.points + calculate_badge_points(user, badges) + self.assertEqual(user.profile.points - points_before, badge.badge_tier * POINTS_BADGE) + + def test_check_badge_conditions(self): + generate_attempts() + user = User.objects.get(id=1) + self.assertEqual(user.profile.earned_badges.count(), 0) + check_badge_conditions(user) + earned_badges = user.profile.earned_badges + self.assertTrue(earned_badges.filter(id_name='create-account').exists()) + self.assertTrue(earned_badges.filter(id_name='attempts-made-1').exists()) + self.assertTrue(earned_badges.filter(id_name='attempts-made-5').exists()) + self.assertTrue(earned_badges.filter(id_name='questions-solved-1').exists()) + self.assertTrue(earned_badges.filter(id_name='consecutive-days-2').exists()) + + def test_get_days_consecutively_answered(self): + generate_attempts() + user = User.objects.get(id=1) + streak = get_days_consecutively_answered(user) + self.assertEqual(streak, 2) + + def test_get_questions_answered_in_past_month(self): + generate_attempts() + user = User.objects.get(id=1) + num_solved = get_questions_answered_in_past_month(user) + self.assertEqual(num_solved, 1) + + def test_backdate_points_correct_second_attempt(self): + user = User.objects.get(id=2) + question = Question.objects.get(slug='question-1') + Attempt.objects.create(profile=user.profile, question=question, passed_tests=False) + Attempt.objects.create(profile=user.profile, question=question, passed_tests=True) + profile = backdate_points(user.profile) + self.assertEqual(profile.points, 10) + + def test_backdate_points_correct_multiple_attempts(self): + user = User.objects.get(id=2) + question = Question.objects.get(slug='question-1') + Attempt.objects.create(profile=user.profile, question=question, passed_tests=True) + Attempt.objects.create(profile=user.profile, question=question, passed_tests=True) + profile = backdate_points(user.profile) + self.assertEqual(profile.points, 12) + + def test_backdate_points_and_badges_too_many_points(self): + generate_attempts() + user = User.objects.get(id=1) + user.profile.points = 1000 + backdate_points_and_badges() + self.assertEqual(User.objects.get(id=1).profile.points, 62) + + def test_backdate_points_and_badges_run_twice(self): + generate_attempts() + user = User.objects.get(id=1) + user.profile.points = 1000 + backdate_points_and_badges() + backdate_points_and_badges() + self.assertEqual(User.objects.get(id=1).profile.points, 62) + earned_badges = User.objects.get(id=1).profile.earned_badges + self.assertEqual(len(earned_badges.filter(id_name='create-account')), 1) + self.assertEqual(len(earned_badges.filter(id_name='attempts-made-1')), 1) + self.assertEqual(len(earned_badges.filter(id_name='attempts-made-5')), 1) + self.assertEqual(len(earned_badges.filter(id_name='questions-solved-1')), 1) + self.assertEqual(len(earned_badges.filter(id_name='consecutive-days-2')), 1) + + def test_backdate_points_and_badges_badge_earnt_no_longer_meets_requirements(self): + user = User.objects.get(id=2) + badge = Badge.objects.get(id_name='attempts-made-5') + Earned.objects.create(profile=user.profile, badge=badge) + backdate_points_and_badges() + self.assertTrue( + User.objects.get(id=2).profile.earned_badges.filter(id_name='attempts-made-5').exists() + ) diff --git a/codewof/tests/programming/test_models.py b/codewof/tests/programming/test_models.py index 99568fbfd..2441a1083 100644 --- a/codewof/tests/programming/test_models.py +++ b/codewof/tests/programming/test_models.py @@ -1,153 +1,454 @@ -# from django.test import TestCase -# from django.core.exceptions import ValidationError -# from django.db.utils import IntegrityError -# from django.contrib.auth.models import User - -# from questions.models import Token, Badge, Profile, Question, Programming, ProgrammingFunction, Buggy, BuggyFunction - - -# class TokenModelTests(TestCase): -# @classmethod -# def setUpTestData(cls): -# Token.objects.create(name='sphere', token='abc') - -# def test_name_unique(self): -# with self.assertRaises(IntegrityError): -# Token.objects.create(name='sphere', token='def') - -# class BadgeModelTests(TestCase): -# @classmethod -# def setUpTestData(cls): -# Badge.objects.create(id_name='solve-40', display_name='first', description='first') - -# def test_id_name_unique(self): -# with self.assertRaises(IntegrityError): -# Badge.objects.create(id_name='solve-40', display_name='second', description='second') - -# class ProfileModelTests(TestCase): +from django.test import TestCase +from django.core.exceptions import ValidationError +from django.db.utils import IntegrityError +from django.contrib.auth import get_user_model +from programming.codewof_utils import check_badge_conditions +from programming.models import ( + Token, + Badge, + Question, + Earned, + Attempt, + QuestionTypeProgram, + QuestionTypeFunction, + QuestionTypeParsons, + QuestionTypeDebugging, +) + +from codewof.tests.codewof_test_data_generator import ( + generate_users, + generate_badges, + generate_questions, + generate_attempts, +) +from codewof.tests.conftest import user + +User = get_user_model() + + +class ProfileModelTests(TestCase): + @classmethod + def setUpTestData(cls): + # never modify this object in tests - read only + generate_users(user) + generate_questions() + generate_badges() + + def test_profile_starts_with_no_points(self): + user = User.objects.get(id=1) + points = user.profile.points + self.assertEqual(points, 0) + + def test_profile_starts_with_create_account_badge(self): + user = User.objects.get(id=1) + check_badge_conditions(user) + badge = Badge.objects.get(id_name="create-account") + earned = Earned.objects.filter(profile=user.profile, badge=badge) + self.assertEqual(len(earned), 1) + + def test_attempted_questions(self): + user = User.objects.get(id=1) + generate_attempts() + attempted_questions = Attempt.objects.filter(profile=user.profile) + # generate_attempts in codewof_utils will generate 5 attempts for user 1 + self.assertEqual(len(attempted_questions), 5) + + def test_profile_starts_on_easiest_goal_level(self): + user = User.objects.get(id=1) + goal = user.profile.goal + self.assertEqual(goal, 1) + + def test_set_goal_to_4(self): + user = User.objects.get(id=2) + user.profile.goal = 4 + user.profile.full_clean() + user.profile.save() + double_check_user = User.objects.get(id=2) + self.assertEqual(double_check_user.profile.goal, 4) + + def test_cannot_set_goal_less_than_1(self): + user = User.objects.get(id=2) + with self.assertRaises(ValidationError): + user.profile.goal = 0 + user.profile.full_clean() + user.profile.save() + double_check_user = User.objects.get(id=2) + self.assertEqual(double_check_user.profile.goal, 1) + + def test_cannot_set_goal_greater_than_7(self): + user = User.objects.get(id=2) + with self.assertRaises(ValidationError): + user.profile.goal = 8 + user.profile.full_clean() + user.profile.save() + double_check_user = User.objects.get(id=2) + self.assertEqual(double_check_user.profile.goal, 1) + + def test_str_representation(self): + user = User.objects.get(id=1) + self.assertEqual(str(user.profile), '{} {}'.format(user.first_name, user.last_name)) + + +class BadgeModelTests(TestCase): + @classmethod + def setUpTestData(cls): + # never modify this object in tests - read only + generate_users(user) + generate_badges() + + def test_id_name_unique(self): + with self.assertRaises(IntegrityError): + Badge.objects.create( + id_name='questions-solved-1', + display_name='second', + description='second' + ) + + def test_badge_tier_zero_default(self): + badge = Badge.objects.create( + id_name='badge_name', + display_name='Dummy Badge', + description='A badge for testing' + ) + self.assertEqual(badge.badge_tier, 0) + + def test_str_representation(self): + badge = Badge.objects.get(id_name='questions-solved-1') + self.assertEqual(str(badge), badge.display_name) + + def test_parent_badge(self): + badge = Badge.objects.get(id_name='attempts-made-1') + parent_id = badge.parent.id_name + self.assertEqual(parent_id, 'attempts-made-5') + + def test_new_user_awards_create_account(self): + user = User.objects.get(pk=1) + check_badge_conditions(user) + badge = Badge.objects.get(id_name="create-account") + earned = Earned.objects.filter(profile=user.profile, badge=badge) + self.assertEqual(len(earned), 1) + + # def test_doesnt_award_twice_create_account(self): + # user = User.objects.get(pk=1) + # badge = Badge.objects.get(id_name="create-account") + # Earned.objects.create(profile=user.profile, badge=badge) + # check_badge_conditions(user) + + # earned = Earned.objects.filter(profile=user.profile, badge=badge) + # self.assertEqual(len(earned), 1) + + # def test_adding_unknown_badge_doesnt_break(self): + # Badge.objects.create(id_name="notrealbadge", display_name="test", description="test") + # user = User.objects.get(pk=1) + # check_badge_conditions(user) + + def test_award_solve_1_on_correct_attempt(self): + user = User.objects.get(pk=1) + question = Question.objects.create(title="Test question", question_text="Print hello world") + Attempt.objects.create(profile=user.profile, question=question, passed_tests=True, user_code='') + + check_badge_conditions(user) + badge = Badge.objects.get(id_name="questions-solved-1") + earned = Earned.objects.filter(profile=user.profile, badge=badge) + self.assertEqual(len(earned), 1) + + def test_not_award_solve_1_on_incorrect_attempt(self): + user = User.objects.get(pk=1) + question = Question.objects.create(title="Test question", question_text="Print hello world") + Attempt.objects.create(profile=user.profile, question=question, passed_tests=False, user_code='') + + check_badge_conditions(user) + badge = Badge.objects.get(id_name="questions-solved-1") + earned = Earned.objects.filter(profile=user.profile, badge=badge) + self.assertEqual(len(earned), 0) + +# def test_no_award_consecutive_login_2(self): +# user = User.objects.get(pk=1) +# login_day = LoginDay.objects.create(profile=user.profile) +# yesterday = datetime.datetime.now() - datetime.timedelta(days=1) +# LoginDay.objects.select_for_update().filter(pk=2).update(day=yesterday.date()) + +# check_badge_conditions(user) +# badge = Badge.objects.get(id_name="login-3") +# earned = Earned.objects.filter(profile=user.profile, badge=badge) +# self.assertEquals(len(earned), 0) + +# def test_award_consecutive_login_3(self): +# user = User.objects.get(pk=1) +# LoginDay.objects.create(profile=user.profile) +# yesterday = datetime.datetime.now() - datetime.timedelta(days=1) +# LoginDay.objects.select_for_update().filter(pk=2).update(day=yesterday.date()) + +# LoginDay.objects.create(profile=user.profile) +# day_before_yesterday = datetime.datetime.now() - datetime.timedelta(days=2) +# LoginDay.objects.select_for_update().filter(pk=3).update(day=day_before_yesterday.date()) + +# check_badge_conditions(user) +# badge = Badge.objects.get(id_name="login-3") +# earned = Earned.objects.filter(profile=user.profile, badge=badge) +# self.assertEquals(len(earned), 1) + +# def test_award_consecutive_login_3_from_last_week(self): +# user = User.objects.get(pk=1) +# LoginDay.objects.create(profile=user.profile) +# last_week3 = datetime.datetime.now() - datetime.timedelta(days=1, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=2).update(day=last_week3.date()) + +# LoginDay.objects.create(profile=user.profile) +# last_week2 = datetime.datetime.now() - datetime.timedelta(days=2, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=3).update(day=last_week2.date()) + +# LoginDay.objects.create(profile=user.profile) +# last_week1 = datetime.datetime.now() - datetime.timedelta(days=3, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=4).update(day=last_week1.date()) + +# check_badge_conditions(user) +# badge = Badge.objects.get(id_name="login-3") +# earned = Earned.objects.filter(profile=user.profile, badge=badge) +# self.assertEquals(len(earned), 1) + +# def test_award_consecutive_login_3_from_last_week_5(self): +# user = User.objects.get(pk=1) +# LoginDay.objects.create(profile=user.profile) +# last_week3 = datetime.datetime.now() - datetime.timedelta(days=1, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=2).update(day=last_week3.date()) + +# LoginDay.objects.create(profile=user.profile) +# last_week2 = datetime.datetime.now() - datetime.timedelta(days=2, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=3).update(day=last_week2.date()) + +# LoginDay.objects.create(profile=user.profile) +# last_week1 = datetime.datetime.now() - datetime.timedelta(days=3, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=4).update(day=last_week1.date()) + +# LoginDay.objects.create(profile=user.profile) +# last_week4 = datetime.datetime.now() - datetime.timedelta(days=4, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=5).update(day=last_week4.date()) + +# LoginDay.objects.create(profile=user.profile) +# last_week5 = datetime.datetime.now() - datetime.timedelta(days=5, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=6).update(day=last_week5.date()) + +# check_badge_conditions(user) +# badge = Badge.objects.get(id_name="login-3") +# earned = Earned.objects.filter(profile=user.profile, badge=badge) +# self.assertEquals(len(earned), 1) + +# def test_no_award_consecutive_login_2_from_last_week(self): +# user = User.objects.get(pk=1) +# LoginDay.objects.create(profile=user.profile) +# last_week3 = datetime.datetime.now() - datetime.timedelta(days=1, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=2).update(day=last_week3.date()) + +# LoginDay.objects.create(profile=user.profile) +# last_week2 = datetime.datetime.now() - datetime.timedelta(days=2, weeks=1) +# LoginDay.objects.select_for_update().filter(pk=3).update(day=last_week2.date()) + +# check_badge_conditions(user) +# badge = Badge.objects.get(id_name="login-3") +# earned = Earned.objects.filter(profile=user.profile, badge=badge) +# self.assertEquals(len(earned), 0) + + +class EarnedModelTests(TestCase): + @classmethod + def setUpTestData(cls): + generate_users(user) + generate_questions() + generate_badges() + generate_attempts() + # award badges + + def test_questions_solved_1_earnt(self): + user = User.objects.get(id=1) + check_badge_conditions(user) + badge = Badge.objects.get(id_name="questions-solved-1") + qs1_earned = Earned.objects.filter(profile=user.profile, badge=badge) + self.assertEqual(len(qs1_earned), 1) + + def test_create_account_earnt(self): + user = User.objects.get(id=1) + check_badge_conditions(user) + badge = Badge.objects.get(id_name="create-account") + create_acc_earned = Earned.objects.filter(profile=user.profile, badge=badge) + self.assertEqual(len(create_acc_earned), 1) + + def test_attempts_made_5_earnt(self): + user = User.objects.get(id=1) + check_badge_conditions(user) + badge = Badge.objects.get(id_name="attempts-made-5") + attempts_5_earned = Earned.objects.filter(profile=user.profile, badge=badge) + self.assertEqual(len(attempts_5_earned), 1) + + def test_attempts_made_1_earnt(self): + user = User.objects.get(id=1) + check_badge_conditions(user) + badge = Badge.objects.get(id_name="attempts-made-1") + attempts1_earned = Earned.objects.filter(profile=user.profile, badge=badge) + self.assertEqual(len(attempts1_earned), 1) + + def test_consecutive_days_2_earnt(self): + user = User.objects.get(id=1) + check_badge_conditions(user) + badge = Badge.objects.get(id_name="consecutive-days-2") + consec_days_earned = Earned.objects.filter(profile=user.profile, badge=badge) + self.assertEqual(len(consec_days_earned), 1) + + +class TokenModelTests(TestCase): + @classmethod + def setUpTestData(cls): + Token.objects.create(name='sphere', token='abc') + + def test_name_unique(self): + with self.assertRaises(IntegrityError): + Token.objects.create(name='sphere', token='def') + + def test_str_representation(self): + token = Token.objects.get(name='sphere') + self.assertEqual(str(token), 'sphere') + + +class QuestionModelTests(TestCase): + @classmethod + def setUpTestData(cls): + # never modify this object in tests - read only + generate_questions() + + def test_question_slug_unique(self): + with self.assertRaises(IntegrityError): + Question.objects.create( + slug='question-1', + title='duplicate', + question_text='' + ) + + def test_get_absolute_url(self): + question = Question.objects.get(slug='question-1') + url = question.get_absolute_url() + self.assertEqual('/questions/{}/'.format(question.id), url) + + def test_str_representation(self): + question = Question.objects.get(slug='question-1') + self.assertEqual(str(question), question.title) + + def test_instance_of_question(self): + question = Question.objects.get_subclass(slug='question-1') + self.assertTrue(isinstance(question, Question)) + + +class QuestionTypeProgramModelTests(TestCase): + @classmethod + def setUpTestData(cls): + # never modify this object in tests - read only + generate_questions() + + def test_question_type_program_instance(self): + program_question = Question.objects.get_subclass(slug="program-question-1") + self.assertTrue(isinstance(program_question, QuestionTypeProgram)) + + def test_question_type_program_verbose_name(self): + program_question = Question.objects.get_subclass(slug="program-question-1") + self.assertEqual(program_question._meta.verbose_name, 'Program Question') + + def test_question_type_program_verbose_name_plural(self): + program_question = Question.objects.get_subclass(slug="program-question-1") + self.assertEqual(program_question._meta.verbose_name_plural, 'Program Questions') + + def test_str_representation(self): + program_question = Question.objects.get_subclass(slug="program-question-1") + self.assertEqual( + str(program_question), + '{}: {}'.format(program_question.QUESTION_TYPE, program_question.title) + ) + + +class QuestionTypeFunctionModelTests(TestCase): + @classmethod + def setUpTestData(cls): + # never modify this object in tests - read only + generate_questions() + + def test_question_type_function_instance(self): + function_question = Question.objects.get_subclass(slug="function-question-1") + self.assertTrue(isinstance(function_question, QuestionTypeFunction)) + + def test_question_type_function_verbose_name(self): + function_question = Question.objects.get_subclass(slug="function-question-1") + self.assertEqual(function_question._meta.verbose_name, 'Function Question') + + def test_question_type_function_verbose_name_plural(self): + function_question = Question.objects.get_subclass(slug="function-question-1") + self.assertEqual(function_question._meta.verbose_name_plural, 'Function Questions') + + def test_str_representation(self): + function_question = Question.objects.get_subclass(slug="function-question-1") + self.assertEqual( + str(function_question), + '{}: {}'.format(function_question.QUESTION_TYPE, function_question.title) + ) + + +class QuestionTypeParsonsModelTests(TestCase): + @classmethod + def setUpTestData(cls): + # never modify this object in tests - read only + generate_questions() + + def test_question_type_parsons_instance(self): + parsons_question = Question.objects.get_subclass(slug="parsons-question-1") + self.assertTrue(isinstance(parsons_question, QuestionTypeParsons)) + + def test_question_type_parsons_verbose_name(self): + parsons_question = Question.objects.get_subclass(slug="parsons-question-1") + self.assertEqual(parsons_question._meta.verbose_name, 'Parsons Problem Question') + + def test_str_representation(self): + parsons_question = Question.objects.get_subclass(slug="parsons-question-1") + self.assertEqual( + str(parsons_question), + '{}: {}'.format(parsons_question.QUESTION_TYPE, parsons_question.title) + ) + + def test_lines_as_list(self): + parsons_question = Question.objects.get_subclass(slug="parsons-question-1") + lines_list = list(parsons_question.lines.split('\n')) + shuffled_lines = parsons_question.lines_as_list() + self.assertEqual(sorted(shuffled_lines), sorted(lines_list)) + + +class QuestionTypeDebuggingModelTests(TestCase): + @classmethod + def setUpTestData(cls): + # never modify this object in tests - read only + generate_questions() + + def test_question_type_debugging_instance(self): + debugging_question = Question.objects.get_subclass(slug="debugging-question-1") + self.assertTrue(isinstance(debugging_question, QuestionTypeDebugging)) + + def test_question_type_function_verbose_name(self): + debugging_question = Question.objects.get_subclass(slug="debugging-question-1") + self.assertEqual(debugging_question._meta.verbose_name, 'Debugging Problem Question') + + def test_str_representation(self): + debugging_question = Question.objects.get_subclass(slug="debugging-question-1") + self.assertEqual( + str(debugging_question), + '{}: {}'.format(debugging_question.QUESTION_TYPE, debugging_question.title) + ) + + def test_read_only_lines_top_default(self): + debugging_question = Question.objects.get_subclass(slug="debugging-question-1") + self.assertEqual(debugging_question.read_only_lines_top, 0) + + def test_read_only_lines_bottom_default(self): + debugging_question = Question.objects.get_subclass(slug="debugging-question-1") + self.assertEqual(debugging_question.read_only_lines_bottom, 0) + + +# class TestCaseModelTests(TestCase): # @classmethod # def setUpTestData(cls): # # never modify this object in tests - read only -# User.objects.create_user(username='john', email='john@uclive.ac.nz', password='onion') - -# def setUp(self): -# # editable version -# User.objects.create_user(username='sally', email='sally@uclive.ac.nz', password='onion') - -# def test_profile_starts_with_no_points(self): -# user = User.objects.get(id=1) -# points = user.profile.points -# self.assertEquals(points, 0) - -# def test_profile_starts_on_easiest_goal_level(self): -# user = User.objects.get(id=1) -# goal = user.profile.goal -# self.assertEquals(goal, 1) - -# def test_set_goal_to_4(self): -# user = User.objects.get(id=2) -# user.profile.goal = 4 -# user.profile.full_clean() -# user.profile.save() -# double_check_user = User.objects.get(id=2) -# self.assertEquals(double_check_user.profile.goal, 4) - -# def test_cannot_set_goal_less_than_1(self): -# user = User.objects.get(id=2) -# with self.assertRaises(ValidationError): -# user.profile.goal = 0 -# user.profile.full_clean() -# user.profile.save() -# double_check_user = User.objects.get(id=2) -# self.assertEquals(double_check_user.profile.goal, 1) - -# def test_cannot_set_goal_greater_than_7(self): -# user = User.objects.get(id=2) -# with self.assertRaises(ValidationError): -# user.profile.goal = 8 -# user.profile.full_clean() -# user.profile.save() -# double_check_user = User.objects.get(id=2) -# self.assertEquals(double_check_user.profile.goal, 1) - -# class QuestionModelTests(TestCase): -# @classmethod -# def setUpTestData(cls): -# # never modify this object in tests - read only -# Question.objects.create(title='Test', question_text='Hello') - -# def setUp(self): -# pass - -# def test_question_text_label(self): -# question = Question.objects.get(id=1) -# field_label = question._meta.get_field('question_text').verbose_name -# self.assertEquals(field_label, 'question text') - -# def test_solution_label(self): -# question = Question.objects.get(id=1) -# field_label = question._meta.get_field('solution').verbose_name -# self.assertEquals(field_label, 'solution') - -# def test_str_question_is_title(self): -# question = Question.objects.get(id=1) -# self.assertEquals(str(question), question.title) - -# class ProgrammingFunctionModelTests(TestCase): -# @classmethod -# def setUpTestData(cls): -# ProgrammingFunction.objects.create(title='Hello', question_text="Hello", function_name="hello") - -# def test_instance_of_question(self): -# question = Question.objects.get_subclass(id=1) -# self.assertTrue(isinstance(question, Question)) - -# def test_instance_of_programming(self): -# question = Question.objects.get_subclass(id=1) -# self.assertTrue(isinstance(question, Programming)) - -# def test_instance_of_programmingfunction(self): -# question = Question.objects.get_subclass(id=1) -# self.assertTrue(isinstance(question, ProgrammingFunction)) - -# def test_not_instance_of_buggy(self): -# question = Question.objects.get_subclass(id=1) -# self.assertFalse(isinstance(question, Buggy)) - -# def test_not_instance_of_buggyfunction(self): -# question = Question.objects.get_subclass(id=1) -# self.assertFalse(isinstance(question, BuggyFunction)) - -# def test_str_question_is_title(self): -# question = Question.objects.get(id=1) -# self.assertEquals(str(question), question.title) - - -# class BuggyModelTests(TestCase): -# @classmethod -# def setUpTestData(cls): -# Buggy.objects.create(title='Hello', question_text="Hello", buggy_program="hello") - -# def test_instance_of_question(self): -# question = Question.objects.get_subclass(id=1) -# self.assertTrue(isinstance(question, Question)) - -# def test_not_instance_of_programming(self): -# question = Question.objects.get_subclass(id=1) -# self.assertFalse(isinstance(question, Programming)) - -# def test_not_instance_of_programmingfunction(self): -# question = Question.objects.get_subclass(id=1) -# self.assertFalse(isinstance(question, ProgrammingFunction)) - -# def test_instance_of_buggy(self): -# question = Question.objects.get_subclass(id=1) -# self.assertTrue(isinstance(question, Buggy)) - -# def test_not_instance_of_buggyfunction(self): -# question = Question.objects.get_subclass(id=1) -# self.assertFalse(isinstance(question, BuggyFunction)) - -# def test_str_question_is_title(self): -# question = Question.objects.get(id=1) -# self.assertEquals(str(question), question.title) +# generate_questions() diff --git a/codewof/tests/programming/test_views.py b/codewof/tests/programming/test_views.py index f3c0bee9f..669d3feaf 100644 --- a/codewof/tests/programming/test_views.py +++ b/codewof/tests/programming/test_views.py @@ -1,6 +1,4 @@ -# flake8: noqa - -# from django.test import TestCase as DjangoTestCase +# from django.test import TestCase # from django.contrib.auth.models import User # from django.contrib.auth import login # from unittest import skip @@ -8,182 +6,82 @@ # import time # import datetime -# from questions.models import * -# from questions.views import * - - -# class ProfileViewTest(DjangoTestCase): +# from programming.models import ( +# Token, +# Badge, +# Question, +# Earned, +# Attempt, +# QuestionTypeProgram, +# ) +# from codewof.tests.codewof_test_data_generator import ( +# generate_users, +# generate_badges, +# generate_questions, +# generate_attempts, +# ) +# from programming.views import ( +# CreateView, +# QuestionListView, +# QuestionView +# ) +# from codewof.tests.conftest import user + + +# class QuestionListViewTest(): + + +# class ProfileViewTest(TestCase): # @classmethod # def setUpTestData(cls): # # never modify this object in tests -# User.objects.create_user(username='john', email='john@uclive.ac.nz', password='onion') +# generate_users(user) + +# def setUp(self): +# self.client = Client() # def login_user(self): -# login = self.client.login(username='john', password='onion') +# login = self.client.login(email='john@uclive.ac.nz', password='onion') # self.assertTrue(login) # ### tests begin ### # def test_redirect_if_not_logged_in(self): -# resp = self.client.get('/profile/') -# self.assertRedirects(resp, '/login/?next=/profile/') +# resp = self.client.get('/users/dashboard/') +# self.assertRedirects(resp, '/accounts/login/?next=/users/dashboard/') # def test_view_url_exists(self): # self.login_user() -# resp = self.client.get('/profile/') +# resp = self.client.get('/users/dashboard/') # self.assertEqual(resp.status_code, 200) # def test_view_uses_correct_template(self): # self.login_user() -# resp = self.client.get('/profile/') +# resp = self.client.get('/users/dashboard/') # self.assertEqual(resp.status_code, 200) -# self.assertTemplateUsed(resp, 'registration/profile.html') +# self.assertTemplateUsed(resp, 'users/dashboard.html') -# class BadgeViewTest(DjangoTestCase): +# class BadgeViewTest(TestCase): # @classmethod # def setUpTestData(cls): # # never modify this object in tests -# user = User.objects.create_user(username='john', email='john@uclive.ac.nz', password='onion') -# LoginDay.objects.create(profile=user.profile) -# Badge.objects.create(id_name="create-account", display_name="test", description="test") -# Badge.objects.create(id_name="login-3", display_name="test", description="test") -# Badge.objects.create(id_name="solve-1", display_name="test", description="test") - -# def test_new_user_awards_create_account(self): -# user = User.objects.get(pk=1) -# check_badge_conditions(user) -# badge = Badge.objects.get(id_name="create-account") -# earned = Earned.objects.filter(profile=user.profile, badge=badge) -# self.assertEquals(len(earned), 1) - -# def test_doesnt_award_twice_create_account(self): -# user = User.objects.get(pk=1) -# badge = Badge.objects.get(id_name="create-account") -# Earned.objects.create(profile=user.profile, badge=badge) -# check_badge_conditions(user) - -# earned = Earned.objects.filter(profile=user.profile, badge=badge) -# self.assertEquals(len(earned), 1) - -# def test_adding_unknown_badge_doesnt_break(self): -# Badge.objects.create(id_name="notrealbadge", display_name="test", description="test") -# user = User.objects.get(pk=1) -# check_badge_conditions(user) - -# def test_no_award_consecutive_login_2(self): -# user = User.objects.get(pk=1) -# login_day = LoginDay.objects.create(profile=user.profile) -# yesterday = datetime.datetime.now() - datetime.timedelta(days=1) -# LoginDay.objects.select_for_update().filter(pk=2).update(day=yesterday.date()) - -# check_badge_conditions(user) -# badge = Badge.objects.get(id_name="login-3") -# earned = Earned.objects.filter(profile=user.profile, badge=badge) -# self.assertEquals(len(earned), 0) - -# def test_award_consecutive_login_3(self): -# user = User.objects.get(pk=1) -# LoginDay.objects.create(profile=user.profile) -# yesterday = datetime.datetime.now() - datetime.timedelta(days=1) -# LoginDay.objects.select_for_update().filter(pk=2).update(day=yesterday.date()) - -# LoginDay.objects.create(profile=user.profile) -# day_before_yesterday = datetime.datetime.now() - datetime.timedelta(days=2) -# LoginDay.objects.select_for_update().filter(pk=3).update(day=day_before_yesterday.date()) - -# check_badge_conditions(user) -# badge = Badge.objects.get(id_name="login-3") -# earned = Earned.objects.filter(profile=user.profile, badge=badge) -# self.assertEquals(len(earned), 1) - -# def test_award_consecutive_login_3_from_last_week(self): -# user = User.objects.get(pk=1) -# LoginDay.objects.create(profile=user.profile) -# last_week3 = datetime.datetime.now() - datetime.timedelta(days=1, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=2).update(day=last_week3.date()) - -# LoginDay.objects.create(profile=user.profile) -# last_week2 = datetime.datetime.now() - datetime.timedelta(days=2, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=3).update(day=last_week2.date()) - -# LoginDay.objects.create(profile=user.profile) -# last_week1 = datetime.datetime.now() - datetime.timedelta(days=3, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=4).update(day=last_week1.date()) - -# check_badge_conditions(user) -# badge = Badge.objects.get(id_name="login-3") -# earned = Earned.objects.filter(profile=user.profile, badge=badge) -# self.assertEquals(len(earned), 1) - -# def test_award_consecutive_login_3_from_last_week_5(self): -# user = User.objects.get(pk=1) +# generate_users(user) +# generate_badges() # LoginDay.objects.create(profile=user.profile) -# last_week3 = datetime.datetime.now() - datetime.timedelta(days=1, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=2).update(day=last_week3.date()) - -# LoginDay.objects.create(profile=user.profile) -# last_week2 = datetime.datetime.now() - datetime.timedelta(days=2, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=3).update(day=last_week2.date()) - -# LoginDay.objects.create(profile=user.profile) -# last_week1 = datetime.datetime.now() - datetime.timedelta(days=3, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=4).update(day=last_week1.date()) - -# LoginDay.objects.create(profile=user.profile) -# last_week4 = datetime.datetime.now() - datetime.timedelta(days=4, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=5).update(day=last_week4.date()) - -# LoginDay.objects.create(profile=user.profile) -# last_week5 = datetime.datetime.now() - datetime.timedelta(days=5, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=6).update(day=last_week5.date()) - -# check_badge_conditions(user) -# badge = Badge.objects.get(id_name="login-3") -# earned = Earned.objects.filter(profile=user.profile, badge=badge) -# self.assertEquals(len(earned), 1) - -# def test_no_award_consecutive_login_2_from_last_week(self): -# user = User.objects.get(pk=1) -# LoginDay.objects.create(profile=user.profile) -# last_week3 = datetime.datetime.now() - datetime.timedelta(days=1, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=2).update(day=last_week3.date()) - -# LoginDay.objects.create(profile=user.profile) -# last_week2 = datetime.datetime.now() - datetime.timedelta(days=2, weeks=1) -# LoginDay.objects.select_for_update().filter(pk=3).update(day=last_week2.date()) - -# check_badge_conditions(user) -# badge = Badge.objects.get(id_name="login-3") -# earned = Earned.objects.filter(profile=user.profile, badge=badge) -# self.assertEquals(len(earned), 0) - -# def test_award_solve_1_on_completed(self): -# user = User.objects.get(pk=1) -# question = Programming.objects.create(title="Test question", question_text="Print hello world") -# attempt = Attempt.objects.create(profile=user.profile, question=question, passed_tests=True, is_save=False, user_code='') - -# check_badge_conditions(user) -# badge = Badge.objects.get(id_name="solve-1") -# earned = Earned.objects.filter(profile=user.profile, badge=badge) -# self.assertEquals(len(earned), 1) - -# def test_not_award_solve_1_on_attempt(self): -# user = User.objects.get(pk=1) -# question = Programming.objects.create(title="Test question", question_text="Print hello world") -# attempt = Attempt.objects.create(profile=user.profile, question=question, passed_tests=False, is_save=False, user_code='') -# check_badge_conditions(user) -# badge = Badge.objects.get(id_name="solve-1") -# earned = Earned.objects.filter(profile=user.profile, badge=badge) -# self.assertEquals(len(earned), 0) # class BuggyQuestionViewTest(DjangoTestCase): # @classmethod # def setUpTestData(cls): # # never modify this object in tests # User.objects.create_user(username='john', email='john@uclive.ac.nz', password='onion') -# question = Buggy.objects.create(title="test", question_text="Print input", solution="i=input()\nprint(i)", buggy_program="i=input()\nprint(i[1:])") +# question = Buggy.objects.create( +# title="test", +# question_text="Print input", +# solution="i=input()\nprint(i)", +# buggy_program="i=input()\nprint(i[1:])" +# ) # token_file = open("../../token_file.txt", "r") # sphere_token = token_file.read().strip() @@ -233,7 +131,12 @@ # self.get_the_output(user_code, buggy_stdin, exp_print, exp_return, 1, '"correct": [true]') # def test_buggy_function(self): -# BuggyFunction.objects.create(title="test", question_text="Print input", function_name="hi", solution="def hi(n):\n return n", buggy_program="def hi(n):\n return n+1") +# BuggyFunction.objects.create( +# title="test", +# question_text="Print input", +# function_name="hi", solution="def hi(n):\n return n", +# buggy_program="def hi(n):\n return n+1" +# ) # user_code = '3' # buggy_stdin = '' @@ -243,7 +146,13 @@ # self.get_the_output(user_code, buggy_stdin, exp_print, exp_return, 2, '"correct": [true]') # def test_buggy_function_and_input_output(self): -# BuggyFunction.objects.create(title="test", question_text="Print input", function_name="hi", solution="def hi(n):\n i=input()\n print(i)\n return n", buggy_program="def hi(n):\n i=input()\n print(i[1:])\n return n") +# BuggyFunction.objects.create( +# title="test", +# question_text="Print input", +# function_name="hi", +# solution="def hi(n):\n i=input()\n print(i)\n return n", +# buggy_program="def hi(n):\n i=input()\n print(i[1:])\n return n" +# ) # user_code = '3' # buggy_stdin = 'hello' @@ -349,7 +258,8 @@ # self.get_the_output(user_code, 2, '"correct": [true]') # def test_get_output_program_escaped_newline_not_replaced(self): -# question = Programming.objects.create(title="Test 2", question_text="Print hello world on different lines using single print") +# question = Programming.objects.create( +# title="Test 2", question_text="Print hello world on different lines using single print") # TestCaseProgram.objects.create(question=question, expected_output="hello\nworld\n") # user_code = 'print("hello\\nworld")' @@ -358,29 +268,36 @@ # # functions # def test_get_output_function(self): -# question = ProgrammingFunction.objects.create(title="Test 2", question_text="Return given word", function_name="direct_return") +# question = ProgrammingFunction.objects.create( +# title="Test 2", question_text="Return given word", function_name="direct_return") # TestCaseFunction.objects.create(question=question, function_params="'hello'", expected_return="'hello'") # user_code = 'def direct_return(word):\n return word' # self.get_the_output(user_code, 2, '"correct": [true]') # def test_get_output_print_function(self): -# question = ProgrammingFunction.objects.create(title="Test 2", question_text="Print given word", function_name="direct_print") -# TestCaseFunction.objects.create(question=question, function_params="'hello'", expected_output="hello\n", expected_return="") +# question = ProgrammingFunction.objects.create( +# title="Test 2", question_text="Print given word", function_name="direct_print") +# TestCaseFunction.objects.create( +# question=question, function_params="'hello'", expected_output="hello\n", expected_return="") # user_code = 'def direct_print(word):\n print(word)' # self.get_the_output(user_code, 2, '"correct": [true]') # def test_get_output_print_and_return_function_multiple_test_cases(self): -# question = ProgrammingFunction.objects.create(title="Test 2", question_text="Print and return given word", function_name="print_return") -# TestCaseFunction.objects.create(question=question, function_params="'hello'", expected_output="hello\n", expected_return="'hello'") -# TestCaseFunction.objects.create(question=question, function_params="'world'", expected_output="world\n", expected_return="'world'") +# question = ProgrammingFunction.objects.create( +# title="Test 2", question_text="Print and return given word", function_name="print_return") +# TestCaseFunction.objects.create( +# question=question, function_params="'hello'", expected_output="hello\n", expected_return="'hello'") +# TestCaseFunction.objects.create( +# question=question, function_params="'world'", expected_output="world\n", expected_return="'world'") # user_code = 'def print_return(word):\n print(word)\n return word' # self.get_the_output(user_code, 2, '"correct": [true, true]') # def test_blank_test_function_multiple_test_cases(self): -# question = ProgrammingFunction.objects.create(title="Test 2", question_text="Return the string doubled", function_name="return_double") +# question = ProgrammingFunction.objects.create( +# title="Test 2", question_text="Return the string doubled", function_name="return_double") # TestCaseFunction.objects.create(question=question, function_params="'hello'", expected_return="'hellohello'") # TestCaseFunction.objects.create(question=question, function_params="''", expected_return="''") @@ -388,15 +305,19 @@ # self.get_the_output(user_code, 2, '"correct": [true, true]') # def test_function_multiple_params(self): -# question = ProgrammingFunction.objects.create(title="Test 2", question_text="Add the strings", function_name="add_words") -# TestCaseFunction.objects.create(question=question, function_params="'good','night'", expected_return="'goodnight'") +# question = ProgrammingFunction.objects.create( +# title="Test 2", question_text="Add the strings", function_name="add_words") +# TestCaseFunction.objects.create( +# question=question, function_params="'good','night'", expected_return="'goodnight'") # user_code = 'def add_words(word1, word2):\n return word1 + word2' # self.get_the_output(user_code, 2, '"correct": [true]') # def test_function_false_for_incorrect_answer(self): -# question = ProgrammingFunction.objects.create(title="Test 2", question_text="Add the strings", function_name="add_words") -# TestCaseFunction.objects.create(question=question, function_params="'good','night'", expected_return="'goodnight'") +# question = ProgrammingFunction.objects.create( +# title="Test 2", question_text="Add the strings", function_name="add_words") +# TestCaseFunction.objects.create( +# question=question, function_params="'good','night'", expected_return="'goodnight'") # user_code = 'def add_words(word1, word2):\n return word1' # self.get_the_output(user_code, 2, '"correct": [false]') diff --git a/codewof/tests/users/test_models.py b/codewof/tests/users/test_models.py index 7bc96f640..cebe3a0ba 100644 --- a/codewof/tests/users/test_models.py +++ b/codewof/tests/users/test_models.py @@ -1,8 +1,44 @@ import pytest -from django.conf import settings +from django.test import TestCase +from users.models import UserType, User + +from codewof.tests.codewof_test_data_generator import generate_users +from codewof.tests.conftest import user pytestmark = pytest.mark.django_db -def test_user_get_absolute_url(user: settings.AUTH_USER_MODEL): - assert user.get_absolute_url() == f"/users/dashboard/" +class UserModelTests(TestCase): + @classmethod + def setUpTestData(cls): + # never modify this object in tests - read only + generate_users(user) + + def test_default_username(self): + user = User.objects.create( + id=3, + first_name='Test', + last_name='Case', + email='testcase@email.com', + password='password', + user_type=UserType.objects.get(slug='other') + ) + self.assertEqual(user.username, 'user' + str(user.id)) + + def test_user_get_absolute_url(self): + user = User.objects.get(id=1) + self.assertEqual(user.get_absolute_url(), '/users/dashboard/') + + def test_str_representation(self): + user = User.objects.get(id=1) + self.assertEqual( + str(user), + '{} {} ({})'.format(user.first_name, user.last_name, user.email) + ) + + def test_full_name_representation(self): + user = User.objects.get(id=1) + self.assertEqual( + user.full_name(), + '{} {}'.format(user.first_name, user.last_name) + ) diff --git a/codewof/users/urls.py b/codewof/users/urls.py index 2feb0127d..8ab747b34 100644 --- a/codewof/users/urls.py +++ b/codewof/users/urls.py @@ -9,4 +9,5 @@ path("dashboard/", view=views.UserDetailView.as_view(), name="dashboard"), path("redirect/", view=views.UserRedirectView.as_view(), name="redirect"), path("update/", view=views.UserUpdateView.as_view(), name="update"), + path("achievements/", view=views.UserAchievementsView.as_view(), name="achievements"), ] diff --git a/codewof/users/views.py b/codewof/users/views.py index b6fe70290..d8ea22ab7 100644 --- a/codewof/users/views.py +++ b/codewof/users/views.py @@ -13,12 +13,31 @@ from rest_framework.permissions import IsAdminUser from users.serializers import UserSerializer from programming import settings -from programming.models import Question, Attempt from users.forms import UserChangeForm from research.models import StudyRegistration + +from programming.models import ( + Question, + Attempt, + Badge +) + +from programming.codewof_utils import check_badge_conditions, get_questions_answered_in_past_month + User = get_user_model() + logger = logging.getLogger(__name__) +del logging + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'incremental': True, + 'root': { + 'level': 'DEBUG', + }, +} class UserDetailView(LoginRequiredMixin, DetailView): @@ -34,6 +53,7 @@ def get_object(self): def get_context_data(self, **kwargs): """Get additional context data for template.""" + user = self.request.user context = super().get_context_data(**kwargs) now = timezone.now() today = now.date() @@ -58,7 +78,7 @@ def get_context_data(self, **kwargs): log_message = 'Questions for user {} on {} ({}):\n'.format(self.request.user, now, today) for i, question in enumerate(questions): log_message += '{}: {}\n'.format(i, question) - logging.info(log_message) + logger.info(log_message) # TODO: Also filter by questions added before today questions = questions.filter( @@ -71,7 +91,7 @@ def get_context_data(self, **kwargs): log_message = 'Filtered questions for user {}:\n'.format(self.request.user) for i, question in enumerate(questions): log_message += '{}: {}\n'.format(i, question) - logging.info(log_message) + logger.info(log_message) # Randomly pick 3 based off seed of todays date if len(questions) > 0: @@ -94,7 +114,7 @@ def get_context_data(self, **kwargs): log_message = 'Chosen questions for user {}:\n'.format(self.request.user) for i, question in enumerate(todays_questions): log_message += '{}: {}\n'.format(i, question) - logging.info(log_message) + logger.info(log_message) context['questions_to_do'] = todays_questions context['all_complete'] = all_complete @@ -111,6 +131,13 @@ def get_context_data(self, **kwargs): study_group__in=study.groups.all(), ).exists() context['studies'] = studies + context['codewof_profile'] = self.object.profile + context['goal'] = user.profile.goal + context['all_badges'] = Badge.objects.all() + questions_answered = get_questions_answered_in_past_month(user) + context['num_questions_answered'] = questions_answered + logger.warning(questions_answered) + check_badge_conditions(user) return context @@ -139,6 +166,27 @@ def get_redirect_url(self): return reverse("users:dashboard") +class UserAchievementsView(LoginRequiredMixin, DetailView): + """View for a user's achievements.""" + + model = User + context_object_name = 'user' + template_name = 'users/achievements.html' + + def get_object(self): + """Get object for template.""" + return self.request.user + + def get_context_data(self, **kwargs): + """Get additional context data for template.""" + user = self.request.user + context = super().get_context_data(**kwargs) + context['badges_not_earned'] = Badge.objects.all().difference(user.profile.earned_badges.all()) + context['num_badges_earned'] = user.profile.earned_badges.all().count() + context['num_badges'] = Badge.objects.all().count() + return context + + class UserAPIViewSet(viewsets.ReadOnlyModelViewSet): """API endpoint that allows users to be viewed.""" diff --git a/dev b/dev index b60b4d977..17fe3cb4a 100755 --- a/dev +++ b/dev @@ -170,6 +170,12 @@ cmd_load_questions() { } defhelp load_questions "Load questions." +# Run load_badges command +cmd_load_badges() { + docker-compose exec django /docker_venv/bin/python3 ./manage.py load_badges +} +defhelp load_badges "Load badges." + cmd_createsuperuser() { docker-compose exec django /docker_venv/bin/python3 ./manage.py createsuperuser } @@ -326,6 +332,11 @@ cmd_dev() { fi } +cmd_backdate() { + docker-compose exec django /docker_venv/bin/python3 ./manage.py backdate_points_and_badges +} +defhelp backdate 'Re-calculates points and badges earned for all user profiles.' + # If no command given if [ $# -eq 0 ]; then echo -e "${RED}ERROR: This script requires a command!${NC}" diff --git a/infrastructure/prod-deploy/update-content.sh b/infrastructure/prod-deploy/update-content.sh index 9a969413e..3890788c0 100755 --- a/infrastructure/prod-deploy/update-content.sh +++ b/infrastructure/prod-deploy/update-content.sh @@ -16,3 +16,5 @@ source ./codewof/load-prod-envs.sh ./dev migrate docker-compose exec django /docker_venv/bin/python3 ./manage.py load_user_types docker-compose exec django /docker_venv/bin/python3 ./manage.py load_questions +docker-compose exec django /docker_venv/bin/python3 ./manage.py load_badges +docker-compose exec django /docker_venv/bin/python3 ./manage.py backdate diff --git a/requirements/local.txt b/requirements/local.txt index 0ca7def1c..7c450fa59 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -6,7 +6,7 @@ psycopg2-binary==2.8.4 # https://github.com/psycopg/psycopg2 # Testing # ------------------------------------------------------------------------------ mypy==0.770 # https://github.com/python/mypy -pytest==5.4.1 # https://github.com/pytest-dev/pytest +pytest==5.3.5 # https://github.com/pytest-dev/pytest pytest-sugar==0.9.2 # https://github.com/Frozenball/pytest-sugar # Code quality