diff --git a/conf/local_settings.py.example b/conf/local_settings.py.example index 917b6dc..506ac1b 100644 --- a/conf/local_settings.py.example +++ b/conf/local_settings.py.example @@ -18,3 +18,5 @@ EXTERNAL_LOGINS = { 'use_smtp': False, } } +FEEDBACK_ENDPOINT = 'https://example.com/bot/' # or None +FEEDBACK_KEY = '1145141919811' diff --git a/conf/settings/dev.py b/conf/settings/dev.py index d5226a0..5aa95a4 100644 --- a/conf/settings/dev.py +++ b/conf/settings/dev.py @@ -19,6 +19,10 @@ 'request': { 'format': '%(asctime)s %(ip)s %(userid)s %(levelname)s %(message)s', }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, }, 'filters': { 'add_user_info': { @@ -39,6 +43,11 @@ 'handlers': ['request'], 'propagate': False, }, + 'custom': { + 'handlers': ['console'], + 'level': 'DEBUG', + 'propagate': False, + } }, 'handlers': { 'databaselog': { @@ -52,6 +61,11 @@ 'class': 'logging.StreamHandler', 'formatter': 'request', }, + 'console': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + } }, } diff --git a/conf/settings/hackergame.py b/conf/settings/hackergame.py index 72f8da1..fca7cbf 100644 --- a/conf/settings/hackergame.py +++ b/conf/settings/hackergame.py @@ -38,6 +38,10 @@ 'request': { 'format': '%(asctime)s %(ip)s %(userid)s %(levelname)s %(message)s', }, + 'verbose': { + 'format': '{levelname} {asctime} [{name}] {message}', + 'style': '{', + }, }, 'filters': { 'add_user_info': { @@ -58,6 +62,11 @@ 'class': 'logging.StreamHandler', 'formatter': 'request', }, + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, }, 'loggers': { 'django': { @@ -74,6 +83,11 @@ 'handlers': ['request'], 'propagate': False, }, + 'custom': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, }, } diff --git a/conf/settings/hgtest.py b/conf/settings/hgtest.py index 00a125d..8d3b471 100644 --- a/conf/settings/hgtest.py +++ b/conf/settings/hgtest.py @@ -37,7 +37,11 @@ }, 'request': { 'format': '%(asctime)s %(ip)s %(userid)s %(levelname)s %(message)s', - } + }, + 'verbose': { + 'format': '{levelname} {asctime} [{name}] {message}', + 'style': '{', + }, }, 'filters': { 'add_user_info': { @@ -58,6 +62,11 @@ 'class': 'logging.StreamHandler', 'formatter': 'request', }, + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose', + }, }, 'loggers': { 'django': { @@ -74,6 +83,11 @@ 'handlers': ['request'], 'propagate': False, }, + 'custom': { + 'handlers': ['console'], + 'level': 'INFO', + 'propagate': False, + }, }, } diff --git a/frontend/admin.py b/frontend/admin.py index 62ca49f..8cb1e60 100644 --- a/frontend/admin.py +++ b/frontend/admin.py @@ -9,9 +9,9 @@ from server.terms.models import Terms from server.trigger.models import Trigger from server.user.models import User -from .models import Page, Account, Code, AccountLog, SpecialProfileUsedRecord, Qa, Credits +from .models import Page, Account, Code, AccountLog, SpecialProfileUsedRecord, Qa, Credits, UnidirectionalFeedback, Feedback -admin.site.register([Page, Account, Code, Qa, Credits, SpecialProfileUsedRecord]) +admin.site.register([Page, Account, Code, Qa, Credits, SpecialProfileUsedRecord, UnidirectionalFeedback, Feedback]) class PermissionListFilter(admin.SimpleListFilter): diff --git a/frontend/migrations/0010_unidirectionalfeedback.py b/frontend/migrations/0010_unidirectionalfeedback.py new file mode 100644 index 0000000..f474b3d --- /dev/null +++ b/frontend/migrations/0010_unidirectionalfeedback.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.16 on 2024-10-24 15:52 + +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), + ("frontend", "0009_alter_accountlog_account"), + ] + + operations = [ + migrations.CreateModel( + name="UnidirectionalFeedback", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "challenge_id", + models.IntegerField(help_text="该反馈对应 Challenge 的 ID"), + ), + ( + "contents", + models.TextField(help_text="反馈内容,最长 1024", max_length=1024), + ), + ( + "submit_datetime", + models.DateTimeField(auto_now_add=True, help_text="反馈时间"), + ), + ( + "user", + models.ForeignKey( + help_text="反馈用户", + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/frontend/migrations/0011_feedback.py b/frontend/migrations/0011_feedback.py new file mode 100644 index 0000000..4181acf --- /dev/null +++ b/frontend/migrations/0011_feedback.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.16 on 2024-10-25 06:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("frontend", "0010_unidirectionalfeedback"), + ] + + operations = [ + migrations.CreateModel( + name="Feedback", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "content", + models.TextField( + blank=True, help_text="会被放入 div 的 HTML" + ), + ), + ], + ), + ] diff --git a/frontend/models.py b/frontend/models.py index 0ba56a1..17b6047 100644 --- a/frontend/models.py +++ b/frontend/models.py @@ -117,3 +117,24 @@ class Credits(models.Model): @classmethod def get(cls): return cls.objects.get_or_create()[0] + + +class Feedback(models.Model): + content = models.TextField(blank=True, help_text='会被放入 div 的 HTML') + + @classmethod + def get(cls): + return cls.objects.get_or_create()[0] + + +class UnidirectionalFeedback(models.Model): + """ + User could submit feedback for a specific challenge. + """ + challenge_id = models.IntegerField(help_text="该反馈对应 Challenge 的 ID") + user = models.ForeignKey(get_user_model(), models.CASCADE, help_text="反馈用户") + contents = models.TextField(max_length=1024, help_text="反馈内容,最长 1024") + submit_datetime = models.DateTimeField(auto_now_add=True, help_text="反馈时间") + + def __str__(self) -> str: + return f"{self.user} 对题目 {self.challenge_id} 的反馈" diff --git a/frontend/templates/challenge_feedback.html b/frontend/templates/challenge_feedback.html new file mode 100644 index 0000000..0c567b4 --- /dev/null +++ b/frontend/templates/challenge_feedback.html @@ -0,0 +1,58 @@ +{% extends 'base.html' %} +{% load static %} + +{% block js %} + {{ block.super }} + + + +{% endblock %} + +{% block content %} + +
+

提交对题目 {{challenge_name}} 的反馈

+
{{ feedback.content|safe }}
+{% verbatim %} +
+ {% endverbatim %} +
+
+ {% csrf_token %} + +
+ +
+
+
+ {% verbatim %} +
+
+

你对该题目上一次提交反馈在 {{ human_latest_submit() }},需要等待提交后一小时方可再次提交。

+
+
+{% endverbatim %} +{{ too_frequent|json_script:'too-frequent' }} +{{ latest_submit|json_script:'latest-submit' }} + +{% endblock %} diff --git a/frontend/templates/hub.html b/frontend/templates/hub.html index 4510826..8f0d566 100644 --- a/frontend/templates/hub.html +++ b/frontend/templates/hub.html @@ -145,6 +145,7 @@

{{ opened.name }}

+ 需要提交反馈?
@@ -259,6 +260,9 @@

{{ opened.name }}

get_subchallenge_count(challenge, flag_index) { let count = (this.clear_count.flags.find(i => i.challenge === challenge.pk && i.flag === flag_index) || {count: 0}).count; return count; + }, + get_feedback_url(challenge) { + return `/challenge/${opened.pk}/feedback/` } }, updated: function () { diff --git a/frontend/urls.py b/frontend/urls.py index 113f1a1..5741097 100644 --- a/frontend/urls.py +++ b/frontend/urls.py @@ -22,6 +22,7 @@ path('error/', views.ErrorView.as_view()), path('data/core.json', views.CoreDataView.as_view(), name='coredata'), path('challenge//', views.ChallengeURLView.as_view(), name='challenge_url'), + path('challenge//feedback/', views.ChallengeFeedbackURLView.as_view(), name='challenge_feedback_url'), path('score/', views.ScoreView.as_view(), name='score'), path('profile/ustc/', views.UstcProfileView.as_view(), name='ustcprofile'), diff --git a/frontend/views.py b/frontend/views.py index b57c80c..5d09855 100644 --- a/frontend/views.py +++ b/frontend/views.py @@ -1,5 +1,7 @@ import json from urllib.parse import quote +from datetime import timedelta +import requests from django.contrib import messages from django.contrib.admin import site @@ -8,6 +10,8 @@ from django.shortcuts import redirect from django.template.response import TemplateResponse from django.views import View +from django.utils import timezone +from django.conf import settings from server.announcement.interface import Announcement from server.challenge.interface import Challenge @@ -18,7 +22,11 @@ from server.context import Context from server.exceptions import Error, NotFound, WrongFormat -from frontend.models import Account, AccountLog, Credits, Qa, SpecialProfileUsedRecord +from frontend.models import Account, AccountLog, Credits, Qa, SpecialProfileUsedRecord, UnidirectionalFeedback, Feedback + +import logging + +logger = logging.getLogger('custom') # noinspection PyMethodMayBeStatic @@ -227,6 +235,97 @@ def get(self, request, challenge_id): return redirect('hub') +class ChallengeFeedbackURLView(View): + def check(self, challenge_id): + request = self.request + context = Context.from_request(request) + try: + Trigger.test_can_submit(context) + User.test_authenticated(context) + challenge = Challenge.get(context, challenge_id) + return challenge + except Error as e: + return None + + def check_frequency(self, challenge_id): + request = self.request + matched_feedbacks = UnidirectionalFeedback.objects.filter(challenge_id=challenge_id, user=request.user) + too_frequent = False + latest = None + if matched_feedbacks: + latest_feedback = matched_feedbacks.latest('submit_datetime') + latest = latest_feedback.submit_datetime + + current = timezone.now() + if current - latest <= timedelta(hours=1): + too_frequent = True + + return too_frequent, latest + + def return_template(self, challenge_name, too_frequent, latest): + return TemplateResponse(self.request, 'challenge_feedback.html', { + "feedback": Feedback.get(), + "challenge_name": challenge_name, + "too_frequent": too_frequent, + "latest_submit": latest, + }) + + def get(self, request, challenge_id): + # check if this is set, even as None + # to make admins quickly notice if they forgot this... + if settings.FEEDBACK_ENDPOINT: + assert settings.FEEDBACK_KEY + challenge = self.check(challenge_id) + if not challenge: + messages.error(request, "反馈功能不可用。") + return redirect('hub') + challenge_name = challenge.name + + too_frequent, latest = self.check_frequency(challenge_id) + + return self.return_template(challenge_name, too_frequent, latest) + + def post(self, request, challenge_id): + challenge = self.check(challenge_id) + if not challenge: + return redirect('hub') + challenge_name = challenge.name + too_frequent, latest = self.check_frequency(challenge_id) + if too_frequent: + messages.error(request, "提交反馈太过频繁。") + return redirect('hub') + contents = request.POST.get("contents") + if len(contents) > 1024: + messages.error(request, "提交内容超过字数限制。") + return self.return_template(challenge_name, too_frequent, latest) + user = User.get(Context.from_request(request), request.user.pk) + # send to user-defined endpoint + if settings.FEEDBACK_ENDPOINT: + try: + response = requests.post( + url=settings.FEEDBACK_ENDPOINT, + headers={ + 'Authorization': 'Bearer ' + settings.FEEDBACK_KEY, + }, + json={ + 'user_id': user.pk, + 'contents': contents, + 'challenge_name': challenge_name, + }, + timeout=15 + ) + response.raise_for_status() + except (requests.exceptions.RequestException, requests.exceptions.HTTPError) as e: + messages.error(request, "反馈发送失败,请向管理员反馈此问题。") + logger.exception("反馈发送失败") + return self.return_template(challenge_name, too_frequent, latest) + feedback = UnidirectionalFeedback.objects.create(challenge_id=challenge_id, user=request.user, contents=contents) + feedback.save() + + messages.success(request, "反馈提交成功。") + return redirect('hub') + + class ScoreView(View): def get(self, request): return TemplateResponse(request, 'score.html')