Unverified Commit 377e2db6 authored by Julien Palard's avatar Julien Palard
Browse files

Hello tox.

parent 2c403463
......@@ -50,7 +50,7 @@ pip install -r requirements.txt
./manage.py migrate
./manage.py loaddata initial # To get some users and exercises
# (Initial data can be updated using:
./manage.py dumpdata -e contenttypes -e auth -e sessions -o website/fixtures/initial.json)
./manage.py dumpdata -e contenttypes -e sessions -o website/fixtures/initial.json)
./manage.py createsuperuser
./manage.py runserver
```
......
from hkis.celery import app as app
from hkis.celery import app
__all__ = ("app",)
......@@ -7,8 +7,3 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hkis.settings")
app = Celery("hkis")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
@app.task(bind=True)
def debug_task(self):
print("Request: {0!r}".format(self.request))
......@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/2.0/ref/settings/
"""
import os
from typing import List
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
......@@ -274,7 +275,7 @@ ALLOWED_ATTRIBUTES = {
"p": ["align", "class"],
}
ALLOWED_STYLES = []
ALLOWED_STYLES: List[str] = []
GIT_HEAD = "master" # Changed in production to the current commit hash, can be used for static file invalidation.
......
"""hkis URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/2.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
"""hkis URL Configuration."""
import debug_toolbar
from django.contrib import admin
from django.shortcuts import redirect
from django.conf import settings
from django.urls import path, include
from django.views.i18n import JavaScriptCatalog
from website.api import router, test_check
from website.api import router
import website.views as views
urlpatterns = [
......@@ -26,8 +13,7 @@ urlpatterns = [
path("jsi18n/", JavaScriptCatalog.as_view(), name="javascript-catalog"),
path("page/<slug:url>", views.old_page, name="oldpage"),
path("teams/", views.teams, name="teams"),
path("test-check/", test_check, name="test-check"),
path("teams/<slug:slug>", views.team, name="team"),
path("teams/<slug:slug>", views.team_view, name="team"),
path("teams/<slug:slug>/stats", views.team_stats, name="team_stats"),
path("admin/", admin.site.urls),
path("api/", include(router.urls)),
......@@ -45,9 +31,5 @@ urlpatterns = [
path(
"favicon.ico", lambda request: redirect("/static/favicon.png", permanent=True)
),
path("__debug__/", include(debug_toolbar.urls)),
]
if settings.DEBUG:
import debug_toolbar
urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns
-r requirements.txt
black
coverage
flake8
mypy
pip-tools
......
#
# This file is autogenerated by pip-compile
# This file is autogenerated by pip-compile with python 3.9
# To update, run:
#
# pip-compile requirements-dev.in
......@@ -57,37 +57,39 @@ cffi==1.14.5
# via
# -r requirements.txt
# cryptography
channels-redis==3.2.0
# via -r requirements.txt
channels==3.0.3
# via
# -r requirements.txt
# channels-redis
click-didyoumean==0.0.3
channels-redis==3.2.0
# via -r requirements.txt
click==7.1.2
# via
# -r requirements.txt
# black
# celery
click-plugins==1.1.1
# click-didyoumean
# click-plugins
# click-repl
# pip-tools
click-didyoumean==0.0.3
# via
# -r requirements.txt
# celery
click-repl==0.1.6
click-plugins==1.1.1
# via
# -r requirements.txt
# celery
click==7.1.2
click-repl==0.1.6
# via
# -r requirements.txt
# black
# celery
# click-didyoumean
# click-plugins
# click-repl
# pip-tools
constantly==15.1.0
# via
# -r requirements.txt
# twisted
coverage==6.1.1
# via -r requirements-dev.in
cryptography==3.4.7
# via
# -r requirements.txt
......@@ -100,6 +102,17 @@ daphne==3.0.2
# channels
distlib==0.3.1
# via virtualenv
django==3.2.2
# via
# -r requirements.txt
# channels
# django-ace
# django-debug-toolbar
# django-extensions
# django-filter
# django-model-utils
# django-modeltranslation
# djangorestframework
django-ace==1.0.10
# via -r requirements.txt
django-crispy-forms==1.11.2
......@@ -116,17 +129,6 @@ django-modeltranslation==0.17.1
# via -r requirements.txt
django-registration-redux==2.9
# via -r requirements.txt
django==3.2.2
# via
# -r requirements.txt
# channels
# django-ace
# django-debug-toolbar
# django-extensions
# django-filter
# django-model-utils
# django-modeltranslation
# djangorestframework
djangorestframework==3.12.4
# via -r requirements.txt
filelock==3.0.12
......@@ -173,12 +175,12 @@ msgpack==1.0.2
# via
# -r requirements.txt
# channels-redis
mypy==0.812
# via -r requirements-dev.in
mypy-extensions==0.4.3
# via
# black
# mypy
mypy==0.812
# via -r requirements-dev.in
packaging==20.9
# via
# -r requirements.txt
......@@ -198,14 +200,14 @@ prompt-toolkit==3.0.18
# click-repl
py==1.10.0
# via tox
pyasn1-modules==0.2.8
pyasn1==0.4.8
# via
# -r requirements.txt
# pyasn1-modules
# service-identity
pyasn1==0.4.8
pyasn1-modules==0.2.8
# via
# -r requirements.txt
# pyasn1-modules
# service-identity
pycodestyle==2.7.0
# via flake8
......@@ -217,15 +219,15 @@ pyflakes==2.3.1
# via flake8
pygments==2.9.0
# via -r requirements.txt
pylint-django==2.4.4
# via -r requirements-dev.in
pylint-plugin-utils==0.6
# via pylint-django
pylint==2.8.2
# via
# -r requirements-dev.in
# pylint-django
# pylint-plugin-utils
pylint-django==2.4.4
# via -r requirements-dev.in
pylint-plugin-utils==0.6
# via pylint-django
pyopenssl==20.0.1
# via
# -r requirements.txt
......
[flake8]
max-line-length = 88
max-complexity = 10
exclude = website/migrations/,hkis/settings.py
;W503 for black (operators and newlines)
ignore = W503
[coverage:run]
; branch = true
parallel = true
omit =
.tox/*
[coverage:report]
skip_covered = True
show_missing = True
exclude_lines =
pragma: no cover
def __repr__
if self\.debug
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
[tox]
envlist = py37, py38, py39, flake8, mypy, black, pylint, coverage
isolated_build = True
skip_missing_interpreters = True
[testenv]
skip_install = True
deps = -r requirements-dev.txt
commands = coverage run manage.py test
setenv =
COVERAGE_FILE={toxworkdir}/.coverage.{envname}
[testenv:coverage]
depends = py37, py38, py39
parallel_show_output = True
setenv = COVERAGE_FILE={toxworkdir}/.coverage
commands =
coverage combine
coverage report --fail-under 70
[testenv:flake8]
commands = flake8 hkis/ website/
[testenv:black]
commands = black --check --diff --exclude migrations/ hkis/ website/
[testenv:mypy]
commands = mypy --ignore-missing hkis/ website/
[testenv:pylint]
commands = pylint --ignore settings.py,tests.py --disable=too-many-ancestors --load-plugins pylint_django --django-settings-module=hkis.settings hkis website
[testenv:pydocstyle]
commands = pydocstyle oeis.py
......@@ -2,8 +2,6 @@ from django import forms
from django.core.exceptions import FieldError
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from django.utils.safestring import mark_safe
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_ace import AceWidget
......@@ -24,7 +22,7 @@ from website.models import (
class PageForm(forms.ModelForm):
class Meta:
model = Page
exclude = ()
fields = ("slug", "title", "body", "position", "in_menu")
widgets = {
"body": AceWidget(
mode="markdown", theme="twilight", width="100%", height="800px"
......@@ -35,7 +33,21 @@ class PageForm(forms.ModelForm):
class AdminExerciseForm(forms.ModelForm):
class Meta:
model = Exercise
exclude = ()
fields = (
"title",
"author",
"slug",
"pre_check",
"check",
"is_published",
"solution",
"wording",
"initial_solution",
"position",
"points",
"category",
"page",
)
widgets = {
"solution": AceWidget(
mode="python", theme="twilight", width="100%", height="400px"
......@@ -58,7 +70,18 @@ class AdminExerciseForm(forms.ModelForm):
class AnswerForm(forms.ModelForm):
class Meta:
model = Answer
exclude = ()
fields = (
"exercise",
"source_code",
"is_corrected",
"is_valid",
"is_shared",
"correction_message",
"corrected_at",
"is_unhelpfull",
"votes",
"is_safe",
)
widgets = {
"source_code": AceWidget(
mode="python", theme="twilight", width="100%", height="400px"
......@@ -124,31 +147,35 @@ class ExerciseAdmin(TranslationAdmin):
return self.readonly_fields
return self.readonly_fields + ("author", "category", "is_published")
def formatted_position(self, obj):
@admin.display(description="position")
def formatted_position(self, obj): # pylint: disable=no-self-use
return f"{obj.position:.2f}"
formatted_position.short_description = "position"
def monthly_tries(self, obj):
def monthly_tries(self, obj): # pylint: disable=no-self-use
return (
f"{obj.last_month_tries} ({obj.last_month_tries - obj.prev_month_tries:+})"
)
def monthly_successes(self, obj):
return f"{obj.last_month_successes} ({obj.last_month_successes - obj.prev_month_successes:+})"
def monthly_successes(self, obj): # pylint: disable=no-self-use
return (
f"{obj.last_month_successes} "
f"({obj.last_month_successes - obj.prev_month_successes:+})"
)
def monthly_success_ratio(self, obj):
def monthly_success_ratio(self, obj): # pylint: disable=no-self-use
last_month_ratio = prev_month_ratio = None
if obj.last_month_successes:
last_month_ratio = obj.last_month_successes / obj.last_month_tries
if obj.prev_month_successes:
prev_month_ratio = obj.prev_month_successes / obj.prev_month_tries
if prev_month_ratio is not None and last_month_ratio is not None:
return f"{last_month_ratio:.0%} ({100*(last_month_ratio - prev_month_ratio):+.2f})"
return (
f"{last_month_ratio:.0%} "
f"({100*(last_month_ratio - prev_month_ratio):+.2f})"
)
if last_month_ratio is not None:
return f"{last_month_ratio:.0%}"
else:
return "ø"
return "ø"
class PageAdmin(TranslationAdmin):
......@@ -169,12 +196,14 @@ class TeamAdmin(admin.ModelAdmin):
readonly_fields = ("created_at",)
inlines = (MembershipInline,)
def members_qty(self, team):
def members_qty(self, team): # pylint: disable=no-self-use
return team.members.count()
@admin.action(description="Send to correction bot")
def send_to_correction_bot(modeladmin, request, queryset):
def send_to_correction_bot(
modeladmin, request, queryset
): # pylint: disable=unused-argument
for answer in queryset:
answer.send_to_correction_bot()
......@@ -193,6 +222,7 @@ class TeamFilter(admin.SimpleListFilter):
except FieldError: # to filter on users
if self.value() is not None:
return queryset.filter(teams=self.value())
return None
class MyExercisesFilter(admin.SimpleListFilter):
......
import asyncio
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.models import Group
from django.db.models import Q
from django_filters import rest_framework as filters
from rest_framework import viewsets, response, serializers, routers, permissions
from rest_framework.decorators import api_view, permission_classes
import django_filters
from website.models import Answer, Exercise, Snippet, User, Category, Page
......@@ -182,7 +178,7 @@ class AnswerViewSet(viewsets.ModelViewSet):
queryset = Answer.objects.all()
filterset_class = AnswerFilter
def cb_new_answer(self, instance):
def cb_new_answer(self, instance): # pylint: disable=no-self-use
group = "answers.{}.{}".format(instance.user.id, instance.exercise.id)
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
......@@ -243,34 +239,38 @@ class SnippetViewSet(viewsets.ModelViewSet):
class ExerciseViewSet(viewsets.ModelViewSet):
permission_classes = [ExercisePermission]
queryset = Exercise.objects.all()
serializer_class = ExerciseSerializer
def perform_create(self, serializer):
serializer.save(author=self.request.user)
def list(self, request, *args, **kwargs):
"""Non-paginating copy of super().list, which calls get_serializer once per item."""
"""Non-paginating copy of super().list, which calls get_serializer
once per item.
"""
queryset = list(self.filter_queryset(self.get_queryset()))
return response.Response(
{
"count": len(queryset),
"results": [self.get_serializer(item).data for item in queryset],
"results": [self._get_serializer_for(item).data for item in queryset],
}
)
def get_serializer(self, instance=None, *args, **kwargs):
"""I tweaked the `list` method so the get_serializer always receive an
instance as first parameter (no many=True), so we can specialize serializer
per instance. This is so exercises owner can see / edit their check.
def _get_serializer_for(self, instance):
"""Only used by list, so we can specialize serializer per
instance. This is so exercises owner can see / edit their
check.
"""
kwargs["context"] = self.get_serializer_context()
if self.action == "list":
return ExerciseSerializer(
instance,
*args,
**kwargs,
fields=("url", "title", "wording", "is_published", "category")
context=self.get_serializer_context(),
fields=("url", "title", "wording", "is_published", "category"),
)
return ExerciseSerializer(instance, *args, **kwargs)
return ExerciseSerializer(
instance,
context=self.get_serializer_context(),
)
class CanViewAnswer(permissions.DjangoModelPermissions):
......@@ -278,70 +278,6 @@ class CanViewAnswer(permissions.DjangoModelPermissions):
return request.user.has_perm("website.view_answer")
async def atest_check(code_to_check, exercise, valid_set, invalid_set):
from moulinette.tasks import check_answer
results = await asyncio.gather(
*[
check_answer(
{
"check": code_to_check,
"pre_check": exercise.pre_check,
"source_code": answer.source_code,
}
)
for answer in valid_set
],
*[
check_answer(
{
"check": code_to_check,
"pre_check": exercise.pre_check,
"source_code": answer.source_code,
}
)
for answer in invalid_set
]
)
return zip(valid_set, results[: len(valid_set)]), zip(
invalid_set, results[len(valid_set) :]
)
@api_view(["POST"])
@permission_classes([CanViewAnswer])
def test_check(request):
"""Test a given check code.
This picks ~10 succeeding answers and ~10 failing answers for the
exercise, and run the correction bots on it with the given check.
Then it reports what changed.
"""
exercise = Exercise.objects.get(pk=request.GET.get("exercise"))
valid_set = list(exercise.answers.filter(is_valid=True).order_by("-pk")[:10])
invalid_set = list(exercise.answers.filter(is_valid=False).order_by("-pk")[:10])
check_code = request.body.decode("UTF-8")
results = {"false_positives": [], "false_negatives": []}
valid_results, invalid_results = async_to_sync(atest_check)(
check_code, exercise, valid_set, invalid_set
)
for answer, (is_valid, message) in valid_results:
if not is_valid:
results["false_negatives"].append(
{"source_code": answer.source_code, "message": message}
)
for answer, (is_valid, message) in invalid_results:
if is_valid:
results["false_positives"].append(
{"source_code": answer.source_code, "message": message}
)
return response.Response(results)
router = routers.DefaultRouter()
router.register(r"answers", AnswerViewSet)
router.register(r"snippets", SnippetViewSet)
......
import asyncio
import json
import logging
from typing import Optional
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
......@@ -47,12 +48,12 @@ def db_create_snippet(user: User, source_code):
@database_sync_to_async
def db_get_exercise(exercise_id: int):
def db_get_exercise(exercise_id: int) -> Exercise:
return Exercise.objects.get(id=exercise_id)
@database_sync_to_async
def db_find_uncorrected(answer_id: int, user: User) -> dict:
def db_find_uncorrected(answer_id: int, user: User) -> Optional[dict]:
try:
answer = Answer.objects.get(id=answer_id, user=user, is_corrected=False)
return {
......@@ -107,28 +108,30 @@ def snippet_message(snippet: Snippet):
return message
def log(message, *args):
if args:
message = message + ": " + str(args)
logger.info("WebSocket %s", message)
class ExerciseConsumer(AsyncJsonWebsocketConsumer):
def __init__(self, *args, **kwargs):
self.settings = {}
self.exercise: Optional[Exercise] = None
super().__init__(*args, **kwargs)
def log(self, message, *args):
if args:
message = message + ": " + str(args)
logger.info("WebSocket %s", message)
async def connect(self):
self.log("connect")
log("connect")
self.exercise = await db_get_exercise(
self.scope["url_route"]["kwargs"]["exercise_id"]
)
self.log("accept")
log("accept")
await self.accept()
async def disconnect(self, close_code):
self.log("disconnect")
async def disconnect(self, code):
logger.info("WebSocket disconnect (code=%s)", code)
async def receive_json(self, content):
async def receive_json(self, content, **kwargs):