diff --git a/dmoj/urls.py b/dmoj/urls.py index c7e6d1c1b..6aab40fcc 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -15,9 +15,9 @@ from judge.feed import AtomBlogFeed, AtomCommentFeed, AtomProblemFeed, BlogFeed, CommentFeed, ProblemFeed from judge.sitemap import sitemaps -from judge.views import TitledTemplateView, api, blog, comment, contests, language, license, mailgun, organization, \ - preview, problem, problem_manage, ranked_submission, register, stats, status, submission, tag, tasks, ticket, \ - two_factor, user, widgets +from judge.views import TitledTemplateView, api, badge, blog, comment, contests, language, license, mailgun, \ + organization, preview, problem, problem_manage, ranked_submission, register, stats, status, submission, tag, \ + tasks, ticket, two_factor, user, widgets from judge.views.magazine import MagazinePage from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \ problem_data_file, problem_init_view @@ -191,7 +191,12 @@ def paged_list_view(view, name): path('find', user.user_ranking_redirect, name='user_ranking_redirect'), ])), - path('user', user.UserAboutPage.as_view(), name='user_page'), + path('user', include([ + path('', user.UserAboutPage.as_view(), name='user_page'), + path('/request', badge.RequestAddBadge.as_view(), name='request_badge'), + path('/request/', badge.BadgeRequestDetail.as_view(), + name='request_badge_detail'), + ])), path('edit/profile/', user.edit_profile, name='user_edit_profile'), path('data/prepare/', user.UserPrepareData.as_view(), name='user_prepare_data'), path('data/download/', user.UserDownloadData.as_view(), name='user_download_data'), diff --git a/judge/admin/__init__.py b/judge/admin/__init__.py index c4e2751bd..0f566fca1 100644 --- a/judge/admin/__init__.py +++ b/judge/admin/__init__.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from django.contrib.flatpages.models import FlatPage +from judge.admin.badge import BadgeRequestAdmin from judge.admin.comments import CommentAdmin from judge.admin.contest import ContestAdmin, ContestParticipationAdmin, ContestTagAdmin from judge.admin.interface import BlogPostAdmin, FlatPageAdmin, LicenseAdmin, LogEntryAdmin, NavigationBarAdmin @@ -14,7 +15,7 @@ from judge.admin.tag import TagAdmin, TagGroupAdmin, TagProblemAdmin from judge.admin.taxon import ProblemGroupAdmin, ProblemTypeAdmin from judge.admin.ticket import TicketAdmin -from judge.models import Badge, BlogPost, Comment, CommentLock, Contest, ContestParticipation, \ +from judge.models import Badge, BadgeRequest, BlogPost, Comment, CommentLock, Contest, ContestParticipation, \ ContestTag, Judge, Language, License, MiscConfig, NavigationBar, Organization, \ OrganizationRequest, Problem, ProblemGroup, ProblemType, Profile, Submission, Tag, \ TagGroup, TagProblem, Ticket @@ -45,5 +46,6 @@ admin.site.register(Tag, TagAdmin) admin.site.register(TagGroup, TagGroupAdmin) admin.site.register(TagProblem, TagProblemAdmin) +admin.site.register(BadgeRequest, BadgeRequestAdmin) admin.site.unregister(User) admin.site.register(User, UserAdmin) diff --git a/judge/admin/badge.py b/judge/admin/badge.py new file mode 100644 index 000000000..191c927a4 --- /dev/null +++ b/judge/admin/badge.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + + +class BadgeRequestAdmin(admin.ModelAdmin): + list_display = ('username', 'badge', 'new_badge_name', 'state', 'time') + readonly_fields = ('user', 'cert', 'desc') + + @admin.display(description=_('username'), ordering='user__user__username') + def username(self, obj): + return obj.user.user.username diff --git a/judge/migrations/0208_badge_request.py b/judge/migrations/0208_badge_request.py new file mode 100644 index 000000000..8ccbd00f0 --- /dev/null +++ b/judge/migrations/0208_badge_request.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.19 on 2024-09-21 00:52 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('judge', '0207_org_credit'), + ] + + operations = [ + migrations.CreateModel( + name='BadgeRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('new_badge_name', models.CharField(blank=True, max_length=128, verbose_name='new badge name')), + ('time', models.DateTimeField(auto_now_add=True, verbose_name='request time')), + ('state', models.CharField(choices=[('P', 'Pending'), ('A', 'Approved'), ('R', 'Rejected')], max_length=1, verbose_name='state')), + ('desc', models.TextField(verbose_name='description')), + ('cert', models.FileField(upload_to='certificates', validators=[django.core.validators.FileExtensionValidator(allowed_extensions={'pdf'})], verbose_name='Certificate')), + ('badge', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='badge_requests', to='judge.badge', verbose_name='badge')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='badge_requests', to='judge.profile', verbose_name='user')), + ], + options={ + 'verbose_name': 'badge request', + 'verbose_name_plural': 'badge requests', + }, + ), + ] diff --git a/judge/models/__init__.py b/judge/models/__init__.py index 3644bc406..795994f48 100644 --- a/judge/models/__init__.py +++ b/judge/models/__init__.py @@ -9,7 +9,7 @@ ProblemTranslation, ProblemType, Solution, SubmissionSourceAccess, TranslatedProblemQuerySet from judge.models.problem_data import CHECKERS, ProblemData, ProblemTestCase, problem_data_storage, \ problem_directory_file -from judge.models.profile import Badge, Organization, OrganizationMonthlyUsage, OrganizationRequest, \ +from judge.models.profile import Badge, BadgeRequest, Organization, OrganizationMonthlyUsage, OrganizationRequest, \ Profile, WebAuthnCredential from judge.models.runtime import Judge, Language, RuntimeVersion from judge.models.submission import SUBMISSION_RESULT, Submission, SubmissionSource, SubmissionTestCase diff --git a/judge/models/profile.py b/judge/models/profile.py index 4c76a098d..6406897e5 100644 --- a/judge/models/profile.py +++ b/judge/models/profile.py @@ -8,7 +8,7 @@ import webauthn from django.conf import settings from django.contrib.auth.models import User -from django.core.validators import RegexValidator +from django.core.validators import FileExtensionValidator, RegexValidator from django.db import models from django.db.models import F, Max, Sum from django.urls import reverse @@ -502,3 +502,56 @@ class OrganizationRequest(models.Model): class Meta: verbose_name = _('organization join request') verbose_name_plural = _('organization join requests') + + +class BadgeRequest(models.Model): + user = models.ForeignKey( + Profile, + verbose_name=_('user'), + related_name='badge_requests', + on_delete=models.CASCADE, + ) + badge = models.ForeignKey( + Badge, + verbose_name=_('badge'), + related_name='badge_requests', + on_delete=models.CASCADE, + null=True, + ) + new_badge_name = models.CharField( + max_length=128, + verbose_name=_('new badge name'), + blank=True, + ) + time = models.DateTimeField(verbose_name=_( + 'request time'), auto_now_add=True) + state = models.CharField( + max_length=1, + verbose_name=_('state'), + choices=( + ('P', _('Pending')), + ('A', _('Approved')), + ('R', _('Rejected')), + ), + ) + desc = models.TextField(verbose_name=_('description')) + cert = models.FileField( + verbose_name=_('Certificate'), + upload_to='certificates', + validators=[ + FileExtensionValidator( + allowed_extensions=settings.PDF_STATEMENT_SAFE_EXTS), + ], + ) + + class Meta: + verbose_name = _('badge request') + verbose_name_plural = _('badge requests') + + def __str__(self): + badge_name = ( + self.badge.name + if self.badge and self.badge.name + else self.new_badge_name if self.new_badge_name else 'No Badge' + ) + return f'{self.user.user.username} - {badge_name} - {self.get_state_display()}' diff --git a/judge/views/badge.py b/judge/views/badge.py new file mode 100644 index 000000000..2a41aad08 --- /dev/null +++ b/judge/views/badge.py @@ -0,0 +1,210 @@ +import os + +from django import forms +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import PermissionDenied +from django.http import ( + FileResponse, + Http404, + HttpResponseForbidden, + HttpResponseRedirect, +) +from django.urls import reverse +from django.utils.html import format_html +from django.utils.translation import gettext as _, gettext_lazy, ngettext +from django.views.generic import DetailView, FormView, View + +from judge.models import Badge, BadgeRequest, Profile +from judge.utils.views import TitleMixin + + +def validate_pdf(value): + if not value.name.endswith('.pdf'): + raise forms.ValidationError(_('Only PDF files are allowed.')) + + +class BadgeRequestForm(forms.ModelForm): + class Meta: + model = BadgeRequest + fields = ['badge', 'desc', 'cert', 'new_badge_name'] + + def __init__(self, *args, **kwargs): + super(BadgeRequestForm, self).__init__(*args, **kwargs) + self.fields['badge'].queryset = Badge.objects.all() + self.fields['badge'].required = False + self.fields['cert'].widget.attrs.update({'accept': 'application/pdf'}) + self.fields['cert'].validators.append(validate_pdf) + + def clean(self): + cleaned_data = super().clean() + badge = cleaned_data.get('badge') + new_badge_name = cleaned_data.get('new_badge_name') + + if not badge and not new_badge_name: + raise forms.ValidationError( + 'You must select an existing badge or enter a new badge name.', + ) + + return cleaned_data + + +class RequestAddBadge(LoginRequiredMixin, FormView): + template_name = 'badge/request.html' + form_class = BadgeRequestForm + + def dispatch(self, request, *args, **kwargs): + return super(RequestAddBadge, self).dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + context = super(RequestAddBadge, self).get_context_data(**kwargs) + context['title'] = _('Request a new badge') + return context + + def form_valid(self, form): + badge_request = BadgeRequest() + badge_request.user = Profile.objects.get(user=self.request.user) + badge_request.badge = form.cleaned_data['badge'] + badge_request.desc = form.cleaned_data['desc'] + badge_request.cert = form.cleaned_data['cert'] + badge_request.new_badge_name = form.cleaned_data['new_badge_name'] + badge_request.state = 'P' + badge_request.save() + return HttpResponseRedirect( + reverse('request_badge_detail', args=(badge_request.id,)), + ) + + def form_invalid(self, form): + return super().form_invalid(form) + + +class BadgeRequestDetail(LoginRequiredMixin, TitleMixin, DetailView): + model = BadgeRequest + template_name = 'badge/detail.html' + title = gettext_lazy('Badge request detail') + pk_url_kwarg = 'rpk' + + def get_object(self, queryset=None): + object = super(BadgeRequestDetail, self).get_object(queryset) + profile = self.request.profile + if object.user_id != profile.id and not object.Badge.is_admin(profile): + raise PermissionDenied() + return object + + +BadgeRequestFormSet = forms.modelformset_factory( + BadgeRequest, extra=0, fields=('state',), can_delete=True, +) + + +class BadgeRequestBaseView(LoginRequiredMixin, View): + model = Badge + tab = None + + def get_object(self, queryset=None): + badge = super(BadgeRequestBaseView, self).get_object(queryset) + if not badge.is_admin(self.request.profile): + raise PermissionDenied() + return badge + + def get_requests(self): + queryset = ( + self.object.requests.select_related('user__user') + .defer( + 'user__about', + 'user__notes', + 'user__user_script', + ) + .order_by('-id') + ) + return queryset + + def get_context_data(self, **kwargs): + context = super(BadgeRequestBaseView, self).get_context_data(**kwargs) + context['title'] = _( + 'Managing join requests for %s') % self.object.name + context['content_title'] = format_html( + _('Managing join requests for %s') % " {0}", + self.object.name, + self.object.get_absolute_url(), + ) + context['tab'] = self.tab + return context + + +class BadgeRequestView(BadgeRequestBaseView): + template_name = 'badge/pending.html' + tab = 'pending' + + def get_context_data(self, **kwargs): + context = super(BadgeRequestView, self).get_context_data(**kwargs) + context['formset'] = self.formset + return context + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + self.formset = BadgeRequestFormSet(queryset=self.get_requests()) + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + def get_requests(self): + return super().get_requests().filter(state='P') + + def post(self, request, *args, **kwargs): + self.object = badge = self.get_object() + self.formset = formset = BadgeRequestFormSet( + request.POST, request.FILES, queryset=self.get_requests(), + ) + if formset.is_valid(): + approved, rejected = 0, 0 + for obj in formset.save(): + if obj.state == 'A': + obj.user.badges.add(obj.badge) + approved += 1 + elif obj.state == 'R': + rejected += 1 + messages.success( + request, + ngettext('Approved %d request.', + 'Approved %d requests.', approved) + % approved + '\n' + + ngettext('Rejected %d request.', + 'Rejected %d requests.', rejected) + % rejected, + ) + return HttpResponseRedirect(request.get_full_path()) + return self.render_to_response(self.get_context_data(object=badge)) + + put = post + + +class BadgeRequestLog(BadgeRequestBaseView): + states = ('A', 'R') + tab = 'log' + template_name = 'badge/log.html' + + def get(self, request, *args, **kwargs): + self.object = self.get_object() + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + def get_context_data(self, **kwargs): + context = super(BadgeRequestLog, self).get_context_data(**kwargs) + context['requests'] = self.get_requests().filter(state__in=self.states) + return context + + +def open_certificate(request, filename): + # Check if the user is authenticated and an admin + if not request.user.is_authenticated or not request.user.is_staff: + return HttpResponseForbidden('You do not have permission to view this file.') + + # Path to the PDF file + file_path = os.path.join(settings.MEDIA_ROOT, 'certificates', filename) + + # Check if the file exists + if os.path.exists(file_path): + return FileResponse(open(file_path, 'rb'), content_type='application/pdf') + else: + raise Http404('File does not exist') diff --git a/templates/badge/detail.html b/templates/badge/detail.html new file mode 100644 index 000000000..874d81a19 --- /dev/null +++ b/templates/badge/detail.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} {% block body %} +

Your request has been sent to the administrator.

+{% endblock %} diff --git a/templates/badge/request.html b/templates/badge/request.html new file mode 100644 index 000000000..8795b07d5 --- /dev/null +++ b/templates/badge/request.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block js_media %} + +{% endblock %} + +{% block body %} +
+ {% csrf_token %} +

+

{{ form.badge }}

+
+

+

{{ form.new_badge_name }}

+
+ {% if form.errors and form.errors.__all__ %} +

{{ _(form.errors.__all__[0]) }}

+ {% endif %} +

+

{{ form.desc }}

+

+

{{ form.cert }}

+ {% if form.errors and form.errors.cert %} +

{{ _(form.errors.cert[0]) }}

+ {% endif %} + +
+{% endblock %} \ No newline at end of file diff --git a/templates/badge/tabs.html b/templates/badge/tabs.html new file mode 100644 index 000000000..cb19b8a2e --- /dev/null +++ b/templates/badge/tabs.html @@ -0,0 +1,16 @@ +
+ +
diff --git a/templates/user/user-tabs.html b/templates/user/user-tabs.html index b7fbf2f95..a3a65c93c 100644 --- a/templates/user/user-tabs.html +++ b/templates/user/user-tabs.html @@ -4,6 +4,7 @@ {% if user.user == request.user and not request.official_contest_mode %} {{ make_tab('create', 'fa-plus', url('blog_post_new'), _('Create new blog post')) }} {% endif %} + {{ make_tab('create', 'fa-plus', url('request_badge'), _('Request badges')) }} {{ make_tab('about', 'fa-info-circle', url('user_page', user.user.username), _('About')) }} {{ make_tab('problems', 'fa-puzzle-piece', url('user_problems', user.user.username), _('Statistics')) }} {% if not request.official_contest_mode %}