diff --git a/challenge/migrations/0001_initial.py b/challenge/migrations/0001_initial.py index 72df0d8..5c506d6 100644 --- a/challenge/migrations/0001_initial.py +++ b/challenge/migrations/0001_initial.py @@ -1,6 +1,9 @@ -# Generated by Django 3.2.8 on 2021-10-10 12:04 +# Generated by Django 3.2.8 on 2021-10-17 13:46 +import datetime +from django.conf import settings from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): @@ -8,17 +11,35 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.CreateModel( name='Challenge', fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=100)), - ('order', models.IntegerField(primary_key=True, serialize=False)), - ('type', models.CharField(choices=[('img', 'Image'), ('file', 'File'), ('mp3', 'Audio'), ('mp4', 'Video')], max_length=10)), - ('solution', models.CharField(max_length=100)), + ('description', models.TextField(blank=True, max_length=5000)), + ('order', models.IntegerField()), + ('type', models.CharField(choices=[('img', 'Image'), ('file', 'File'), ('mp3', 'Audio'), ('mp4', 'Video'), ('html', 'HTML Template')], max_length=10)), + ('solution', models.CharField(max_length=1000)), ('file', models.FileField(upload_to='')), + ('activation_date', models.DateTimeField(default=datetime.datetime.now)), + ], + ), + migrations.CreateModel( + name='ChallengeUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_try', models.DateTimeField(default=datetime.datetime.now)), + ('last_try', models.DateTimeField(auto_now_add=True)), + ('attempt_date', models.DateTimeField(default=datetime.datetime.now)), + ('success', models.BooleanField(default=False)), + ('attempts', models.IntegerField(default=0)), + ('total_attempts', models.IntegerField(default=0)), + ('challenge', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='challenge.challenge')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)), ], ), ] diff --git a/challenge/migrations/0002_auto_20211010_1451.py b/challenge/migrations/0002_auto_20211010_1451.py deleted file mode 100644 index 92e0fad..0000000 --- a/challenge/migrations/0002_auto_20211010_1451.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-10 14:51 - -import datetime -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('challenge', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='challenge', - name='description', - field=models.TextField(blank=True, max_length=5000), - ), - migrations.AlterField( - model_name='challenge', - name='solution', - field=models.CharField(max_length=1000), - ), - migrations.CreateModel( - name='ChallengeUser', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('first_try', models.DateTimeField(default=datetime.datetime.now)), - ('last_try', models.DateTimeField(auto_now_add=True)), - ('attempt_date', models.DateTimeField(default=datetime.datetime.now)), - ('success', models.BooleanField(default=False)), - ('attempts', models.IntegerField(default=0)), - ('total_attempts', models.IntegerField(default=0)), - ('challenge', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to='challenge.challenge')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.DO_NOTHING, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/challenge/mixins.py b/challenge/mixins.py index b1e2cc6..7fb4039 100644 --- a/challenge/mixins.py +++ b/challenge/mixins.py @@ -1,18 +1,29 @@ +from datetime import datetime + +import pytz from django.contrib.auth.mixins import AccessMixin +from django.shortcuts import redirect, get_object_or_404 +from django.urls import reverse -from challenge.models import ChallengeUser +from challenge.models import ChallengeUser, Challenge class ChallengePermissionMixin(AccessMixin): def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated: return self.handle_no_permission() - c_id = kwargs.get('id', 1) - if c_id != 1: - c_id -= 1 - try: - if not ChallengeUser.objects.get(challenge_id=c_id, user=request.user).success: - return self.handle_no_permission() - except ChallengeUser.DoesNotExist: + if request.user.username is None: + return redirect(reverse('index')) + if not request.user.is_staff: + c_id = kwargs.get('c_id', 1) + challenge = get_object_or_404(Challenge, pk=c_id) + if pytz.utc.localize(datetime.now()) < challenge.activation_date: return self.handle_no_permission() + if challenge.order != 1: + c_order = challenge.order - 1 + success = ChallengeUser.objects.filter(challenge__order=c_order, user=request.user, + success=True).count() + challenges = Challenge.objects.filter(order=c_order).count() + if challenges > success: + return self.handle_no_permission() return super().dispatch(request, *args, **kwargs) diff --git a/challenge/models.py b/challenge/models.py index 9a797f9..a03a402 100644 --- a/challenge/models.py +++ b/challenge/models.py @@ -1,7 +1,9 @@ from datetime import datetime +import pytz from django.contrib.auth.hashers import make_password, check_password from django.db import models +from django.template import Template, Context class Challenge(models.Model): @@ -9,23 +11,36 @@ class Challenge(models.Model): TYPE_FILE = 'file' TYPE_AUDIO = 'mp3' TYPE_VIDEO = 'mp4' + TYPE_HTML = 'html' TYPES = ( (TYPE_IMAGE, 'Image'), (TYPE_FILE, 'File'), (TYPE_AUDIO, 'Audio'), (TYPE_VIDEO, 'Video'), + (TYPE_HTML, 'HTML Template'), ) name = models.CharField(max_length=100) description = models.TextField(max_length=5000, blank=True) - order = models.IntegerField(primary_key=True) + order = models.IntegerField() type = models.CharField(choices=TYPES, max_length=10) solution = models.CharField(max_length=1000) file = models.FileField() + activation_date = models.DateTimeField(default=datetime.now) def __str__(self): return '%s - %s' % (self.order, self.name) + def active(self): + return self.activation_date <= pytz.utc.localize(datetime.now()) + + def get_template_html(self): + with self.file.open('r') as file: + text = file.read() + template = Template(text) + context = Context({'self': self}) + return template.render(context=context) + # solution will be hard encrypted for security reasons def set_solution(self, solution): self.solution = make_password(solution) @@ -38,7 +53,7 @@ class ChallengeUser(models.Model): user = models.ForeignKey('user.User', on_delete=models.DO_NOTHING) challenge = models.ForeignKey('challenge.Challenge', on_delete=models.DO_NOTHING) first_try = models.DateTimeField(default=datetime.now) - last_try = models.DateTimeField(auto_now_add=True) + last_try = models.DateTimeField(null=True) attempt_date = models.DateTimeField(default=datetime.now) success = models.BooleanField(default=False) attempts = models.IntegerField(default=0) diff --git a/challenge/tables.py b/challenge/tables.py new file mode 100644 index 0000000..3654c14 --- /dev/null +++ b/challenge/tables.py @@ -0,0 +1,17 @@ +import django_tables2 as tables + +from challenge.models import ChallengeUser + + +class ChallengeStatsTable(tables.Table): + first_try = tables.TemplateColumn(template_code='' + '{{ record.first_try|date:"m/d/Y H:i:s T" }}') + last_try = tables.TemplateColumn(template_code='' + '{{ record.last_try|date:"m/d/Y H:i:s T" }}') + + class Meta: + orderable = False + model = ChallengeUser + template_name = "django_tables2/bootstrap.html" + fields = ['user__username', 'user__email', 'total_attempts', 'last_try', 'first_try', 'success'] + empty_text = "User hasn't tried this challenge yet" diff --git a/challenge/templates/challenge.html b/challenge/templates/challenge.html index 80ac9a8..65d8dba 100644 --- a/challenge/templates/challenge.html +++ b/challenge/templates/challenge.html @@ -2,8 +2,22 @@ {% load bootstrap3 %} {% block head_title %}{{ challenge.name }}{% endblock %} {% block content %} +
+
+ < Back +
+ {% if request.user.is_staff %} +
+ Stats +
+ {% endif %} +
+

{{ challenge.name }}

-

{{ challenge.description }}

+ {% if form is None %} + + {% endif %} + {{ challenge.description|safe }}
{% if challenge.type == challenge.TYPE_VIDEO %} @@ -20,13 +34,17 @@

{{ challenge.name }}

Download files {% elif challenge.type == challenge.TYPE_IMAGE %} - {% endif %} + {% elif challenge.type == challenge.TYPE_HTML %} + {{ challenge.get_template_html|safe }} + {% endif %}
-
- {% csrf_token %} - {% bootstrap_form form %} - -
+ {% if form %} +
+ {% csrf_token %} + {% bootstrap_form form %} + +
+ {% endif %} {% endblock %} diff --git a/challenge/templates/challenge_index.html b/challenge/templates/challenge_index.html new file mode 100644 index 0000000..90249d4 --- /dev/null +++ b/challenge/templates/challenge_index.html @@ -0,0 +1,39 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} +{% block head_title %}Challenges{% endblock %} +{% block content %} +

Challenges

+ + {% for phase, group in challenge_groups.items %} +

Phase {{ phase }}

+
+ {% for challenge in group %} + = challenge.order or request.user.is_staff %}href="{% url 'challenge' challenge.pk %}"{% endif %} class="list-group-item" style="font-size: x-large"> +
+
+ Challenge: {{ challenge.name }}
+

{% if challenge.active %}Active now{% else %}Not active. Starts at {{ challenge.activation_date|date:"m/d/Y H:i:s T" }}{% endif %}

+
+
+ {% if challenge.player_try.0.success %}Done{% endif %} +
+
+
+ {% endfor %} +
+ {% endfor %} +{% endblock %} +{% block extra_scripts %} + +{% endblock %} diff --git a/challenge/templates/challenge_stats.html b/challenge/templates/challenge_stats.html new file mode 100644 index 0000000..73cb317 --- /dev/null +++ b/challenge/templates/challenge_stats.html @@ -0,0 +1,45 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} +{% load render_table from django_tables2 %} +{% block head_title %}{{ challenge.name }}{% endblock %} +{% block content %} +
+
+ < Back +
+
+ +

Stats from challenge: {{ challenge.name }}

+

Reloading page in 60 seconds
+ Tried by {{ object_list|length }} users
+ Succeed by {{ succeed }} users

+ + {% render_table table %} + +{% endblock %} +{% block extra_scripts %} + +{% endblock %} diff --git a/challenge/urls.py b/challenge/urls.py index e7d030a..59e4beb 100644 --- a/challenge/urls.py +++ b/challenge/urls.py @@ -1,8 +1,9 @@ from django.urls import path -from challenge.views import HomeView, ChallengeView +from challenge.views import HomeView, ChallengeView, ChallengeStatsView urlpatterns = [ - path('', HomeView.as_view(), name='home'), - path('challenge//', ChallengeView.as_view(), name='challenge'), + path('', HomeView.as_view(), name='challenge-index'), + path('/', ChallengeView.as_view(), name='challenge'), + path('/stats/', ChallengeStatsView.as_view(), name='challenge-stats'), ] diff --git a/challenge/views.py b/challenge/views.py index 0f542e5..4ff0d3b 100644 --- a/challenge/views.py +++ b/challenge/views.py @@ -3,25 +3,44 @@ import pytz from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Prefetch from django.shortcuts import get_object_or_404, render, redirect -from django.urls import reverse -from django.views import View from django.views.generic import TemplateView +from django_tables2 import SingleTableView from challenge.forms import ChallengeTryForm from challenge.mixins import ChallengePermissionMixin from challenge.models import Challenge, ChallengeUser +from challenge.tables import ChallengeStatsTable +from user.mixins import IsStaffMixin -class HomeView(LoginRequiredMixin, View): - def get(self, request): - challenge = ChallengeUser.objects.filter(user=request.user).order_by('-challenge_id') - next_id = 1 - try: - next_id = challenge[0].challenge_id + int(challenge[0].success) - except IndexError: - pass - return redirect(reverse('challenge', args=[next_id])) +class HomeView(LoginRequiredMixin, TemplateView): + template_name = 'challenge_index.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + challenges = Challenge.objects.all().prefetch_related( + Prefetch('challengeuser_set', queryset=ChallengeUser.objects.filter(user=self.request.user), + to_attr='player_try')) + result = {} + player = {} + for challenge in challenges: + try: + aux2 = player.get(challenge.order, 0) + int(challenge.player_try[0].success) + player[challenge.order] = aux2 + except IndexError: + pass + aux = result.get(challenge.order, []) + aux.append(challenge) + result[challenge.order] = aux + max_challenge = sorted(player.items())[-1] or (1, 0) + player_phase = max_challenge[0] + int(max_challenge[1] == len(result[max_challenge[0]])) + context.update({ + 'challenge_groups': result, + 'player_phase': player_phase, + }) + return context class ChallengeView(ChallengePermissionMixin, TemplateView): @@ -30,10 +49,15 @@ class ChallengeView(ChallengePermissionMixin, TemplateView): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) c_id = kwargs.get('c_id') - challenge = get_object_or_404(Challenge, order=c_id) + challenge = get_object_or_404(Challenge, pk=c_id) + try: + succeed = ChallengeUser.objects.get(user=self.request.user, challenge=challenge).success + except ChallengeUser.DoesNotExist: + succeed = False + form = None if succeed else ChallengeTryForm() context.update({ 'challenge': challenge, - 'form': ChallengeTryForm(), + 'form': form }) return context @@ -62,9 +86,29 @@ def post(self, request, **kwargs): challenge_try.total_attempts += 1 if challenge_try.challenge.check_solution(code): challenge_try.success = True + challenge_try.last_try = now challenge_try.save() - return redirect('home') + return redirect('challenge-index') form.add_error('code', 'Invalid code') challenge_try.save() context.update({'form': form}) return render(request, template_name=self.template_name, context=context) + + +class ChallengeStatsView(IsStaffMixin, SingleTableView): + table_class = ChallengeStatsTable + template_name = 'challenge_stats.html' + + def get_queryset(self): + c_id = self.kwargs.get('c_id') + return ChallengeUser.objects.filter(challenge_id=c_id).order_by('-success', 'last_try') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + c_id = self.kwargs.get('c_id') + challenge = get_object_or_404(Challenge, pk=c_id) + succeed = 0 + for c in context.get('object_list', []): + succeed += int(c.success) + context.update({'challenge': challenge, 'succeed': succeed}) + return context diff --git a/ranking/__init__.py b/ranking/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ranking/apps.py b/ranking/apps.py new file mode 100644 index 0000000..7f45056 --- /dev/null +++ b/ranking/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RankingConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ranking' diff --git a/ranking/tables.py b/ranking/tables.py new file mode 100644 index 0000000..7e7217f --- /dev/null +++ b/ranking/tables.py @@ -0,0 +1,15 @@ +import django_tables2 as tables + +from challenge.models import ChallengeUser + + +class RankingTable(tables.Table): + count = tables.Column(verbose_name='Challenges') + time = tables.TemplateColumn(template_code='{{ record.time|date:"m/d/Y H:i:s T" }}') + + class Meta: + orderable = False + model = ChallengeUser + template_name = "django_tables2/bootstrap.html" + fields = ['user__username', 'count', 'time'] + empty_text = "No challenges completed. Run and be the first one to succeed!" diff --git a/ranking/templates/ranking.html b/ranking/templates/ranking.html new file mode 100644 index 0000000..3c97aef --- /dev/null +++ b/ranking/templates/ranking.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} +{% load bootstrap3 %} +{% load render_table from django_tables2 %} +{% block head_title %}Ranking{% endblock %} +{% block content %} +

Ranking

+

Reloading page in 60 seconds

+ {% render_table table %} + +{% endblock %} +{% block extra_scripts %} + +{% endblock %} diff --git a/ranking/urls.py b/ranking/urls.py new file mode 100644 index 0000000..923ddd8 --- /dev/null +++ b/ranking/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from ranking.views import RankingView + +urlpatterns = [ + path('', RankingView.as_view(), name='ranking'), +] diff --git a/ranking/views.py b/ranking/views.py new file mode 100644 index 0000000..e32fa48 --- /dev/null +++ b/ranking/views.py @@ -0,0 +1,15 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db.models import Count, Max +from django_tables2 import SingleTableView + +from challenge.models import ChallengeUser +from ranking.tables import RankingTable + + +class RankingView(LoginRequiredMixin, SingleTableView): + table_class = RankingTable + template_name = 'ranking.html' + + def get_queryset(self): + return ChallengeUser.objects.filter(success=True).values('user__username')\ + .annotate(count=Count('*'), time=Max('last_try')).order_by('-count', '-time') diff --git a/requirements.txt b/requirements.txt index 3825610..7182303 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,5 @@ Django==3.2.8 django-cas-ng==4.2.1 flake8==3.9.2 django-bootstrap3==15.0.0 +django-tables2==2.4.1 +channels-redis==3.3.1 diff --git a/thegame/asgi.py b/thegame/asgi.py deleted file mode 100644 index 0bb6678..0000000 --- a/thegame/asgi.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -ASGI config for thegame project. - -It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/ -""" - -import os - -from django.core.asgi import get_asgi_application - -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'thegame.settings') - -application = get_asgi_application() diff --git a/thegame/settings.py b/thegame/settings.py index 59980d3..f100cea 100644 --- a/thegame/settings.py +++ b/thegame/settings.py @@ -39,8 +39,10 @@ 'django.contrib.staticfiles', 'django_cas_ng', 'bootstrap3', + 'django_tables2', 'user', 'challenge', + 'ranking', ] MIDDLEWARE = [ diff --git a/thegame/static/css/custom-bootstrap.css b/thegame/static/css/custom-bootstrap.css index 5f5b897..cacfc9c 100644 --- a/thegame/static/css/custom-bootstrap.css +++ b/thegame/static/css/custom-bootstrap.css @@ -4974,7 +4974,7 @@ a.label:focus { text-align: center; white-space: nowrap; vertical-align: middle; - background-color: #555555; + background-color: #8a69d4; border-radius: 10px; } .badge:empty { @@ -4998,7 +4998,7 @@ a.badge:focus { .list-group-item.active > .badge, .nav-pills > .active > a > .badge { color: #ffffff; - background-color: #ffffff; + background-color: #333333; } .list-group-item > .badge { float: right; @@ -5295,8 +5295,8 @@ a.thumbnail.active { display: block; padding: 10px 15px; margin-bottom: -1px; - background-color: #ffffff; - border: 1px solid #dddddd; + background-color: #231f2c; + border: 1px solid #333333; } .list-group-item:first-child { border-top-left-radius: 4px; @@ -5310,9 +5310,9 @@ a.thumbnail.active { .list-group-item.disabled, .list-group-item.disabled:hover, .list-group-item.disabled:focus { - color: #555555; + color: #a94442; cursor: not-allowed; - background-color: #eeeeee; + background-color: #f2dede; } .list-group-item.disabled .list-group-item-heading, .list-group-item.disabled:hover .list-group-item-heading, @@ -5322,15 +5322,15 @@ a.thumbnail.active { .list-group-item.disabled .list-group-item-text, .list-group-item.disabled:hover .list-group-item-text, .list-group-item.disabled:focus .list-group-item-text { - color: #555555; + color: #a94442; } .list-group-item.active, .list-group-item.active:hover, .list-group-item.active:focus { z-index: 2; - color: #eeeeee; - background-color: #8a69d4; - border-color: #8a69d4; + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; } .list-group-item.active .list-group-item-heading, .list-group-item.active:hover .list-group-item-heading, @@ -5346,23 +5346,23 @@ a.thumbnail.active { .list-group-item.active .list-group-item-text, .list-group-item.active:hover .list-group-item-text, .list-group-item.active:focus .list-group-item-text { - color: #ffffff; + color: #3c763d; } a.list-group-item, button.list-group-item { - color: #555555; + color: #eeeeee; } a.list-group-item .list-group-item-heading, button.list-group-item .list-group-item-heading { - color: #333333; + color: #eeeeee; } a.list-group-item:hover, button.list-group-item:hover, a.list-group-item:focus, button.list-group-item:focus { - color: #555555; + color: #eeeeee; text-decoration: none; - background-color: #f5f5f5; + background-color: #231f2c; } button.list-group-item { width: 100%; diff --git a/thegame/templates/base.html b/thegame/templates/base.html index 0d89d40..c30aa58 100644 --- a/thegame/templates/base.html +++ b/thegame/templates/base.html @@ -55,7 +55,11 @@