Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add badge request #405

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
13 changes: 9 additions & 4 deletions dmoj/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<int:rpk>', 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'),
Expand Down
4 changes: 3 additions & 1 deletion judge/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
11 changes: 11 additions & 0 deletions judge/admin/badge.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions judge/migrations/0208_badge_request.py
Original file line number Diff line number Diff line change
@@ -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',
},
),
]
2 changes: 1 addition & 1 deletion judge/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 54 additions & 1 deletion judge/models/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()}'
210 changes: 210 additions & 0 deletions judge/views/badge.py
Original file line number Diff line number Diff line change
@@ -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') % " <a href='{1}'>{0}</a>",
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')
3 changes: 3 additions & 0 deletions templates/badge/detail.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{% extends "base.html" %} {% block body %}
<p>Your request has been sent to the administrator.</p>
{% endblock %}
Loading
Loading