Merge from master
This commit is contained in:
commit
7e6cc57c65
227 changed files with 82565 additions and 17221 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -15,4 +15,5 @@ sass_processed
|
||||||
<desired bridge log path>
|
<desired bridge log path>
|
||||||
node_modules/
|
node_modules/
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
/src
|
||||||
|
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
"""
|
|
||||||
ASGI entrypoint. Configures Django and then runs the application
|
|
||||||
defined in the ASGI_APPLICATION setting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import django
|
|
||||||
from channels.routing import get_default_application
|
|
||||||
|
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "dmoj.settings")
|
|
||||||
django.setup()
|
|
||||||
application = get_default_application()
|
|
|
@ -1,76 +0,0 @@
|
||||||
import json
|
|
||||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
|
||||||
from .models import Message
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.http import HttpResponse, HttpResponseRedirect
|
|
||||||
from django.core import serializers
|
|
||||||
|
|
||||||
from judge.jinja2.gravatar import gravatar
|
|
||||||
from judge.models.profile import Profile
|
|
||||||
|
|
||||||
|
|
||||||
class ChatConsumer(AsyncWebsocketConsumer):
|
|
||||||
async def connect(self):
|
|
||||||
self.room_name = 'room'
|
|
||||||
self.room_group_name = 'chat_%s' % self.room_name
|
|
||||||
|
|
||||||
# Join room group
|
|
||||||
await self.channel_layer.group_add(
|
|
||||||
self.room_group_name,
|
|
||||||
self.channel_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.accept()
|
|
||||||
|
|
||||||
async def disconnect(self, close_code):
|
|
||||||
# Leave room group
|
|
||||||
await self.channel_layer.group_discard(
|
|
||||||
self.room_group_name,
|
|
||||||
self.channel_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Receive message from WebSocket
|
|
||||||
async def receive(self, text_data):
|
|
||||||
text_data_json = json.loads(text_data)
|
|
||||||
message = text_data_json['message']
|
|
||||||
|
|
||||||
author = self.scope['user']
|
|
||||||
author = Profile.objects.get(user=author)
|
|
||||||
|
|
||||||
message['author'] = author.username
|
|
||||||
message['css_class'] = author.css_class
|
|
||||||
message['image'] = gravatar(author, 32)
|
|
||||||
|
|
||||||
message_saved = save_data_and_return(message, author)
|
|
||||||
message['time'] = message_saved[0]['fields']['time']
|
|
||||||
message['id'] = message_saved[0]['pk']
|
|
||||||
|
|
||||||
# Send message to room group
|
|
||||||
await self.channel_layer.group_send(
|
|
||||||
self.room_group_name,
|
|
||||||
{
|
|
||||||
'type': 'chat_message',
|
|
||||||
'message': message,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Receive message from room group
|
|
||||||
async def chat_message(self, event):
|
|
||||||
message = event['message']
|
|
||||||
# Send message to WebSocket
|
|
||||||
await self.send(text_data=json.dumps({
|
|
||||||
'message': message,
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
# return time
|
|
||||||
def save_data_and_return(message, author):
|
|
||||||
new_message = Message(body=message['body'],
|
|
||||||
author=author,
|
|
||||||
)
|
|
||||||
new_message.save()
|
|
||||||
json_data = serializers.serialize("json",
|
|
||||||
Message.objects
|
|
||||||
.filter(pk=new_message.id)
|
|
||||||
)
|
|
||||||
return json.loads(json_data)
|
|
28
chat_box/migrations/0005_auto_20211011_0714.py
Normal file
28
chat_box/migrations/0005_auto_20211011_0714.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 2.2.17 on 2021-10-11 00:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('judge', '0116_auto_20211011_0645'),
|
||||||
|
('chat_box', '0004_auto_20200505_2336'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Room',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('user_one', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_one', to='judge.Profile', verbose_name='user 1')),
|
||||||
|
('user_two', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_two', to='judge.Profile', verbose_name='user 2')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='message',
|
||||||
|
name='room',
|
||||||
|
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='chat_box.Room', verbose_name='room id'),
|
||||||
|
),
|
||||||
|
]
|
24
chat_box/migrations/0006_userroom.py
Normal file
24
chat_box/migrations/0006_userroom.py
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 2.2.17 on 2021-11-12 05:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('judge', '0116_auto_20211011_0645'),
|
||||||
|
('chat_box', '0005_auto_20211011_0714'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserRoom',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('last_seen', models.DateTimeField(verbose_name='last seen')),
|
||||||
|
('room', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='chat_box.Room', verbose_name='room id')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='judge.Profile', verbose_name='user')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
18
chat_box/migrations/0007_auto_20211112_1255.py
Normal file
18
chat_box/migrations/0007_auto_20211112_1255.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 2.2.17 on 2021-11-12 05:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('chat_box', '0006_userroom'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='userroom',
|
||||||
|
name='last_seen',
|
||||||
|
field=models.DateTimeField(auto_now_add=True, verbose_name='last seen'),
|
||||||
|
),
|
||||||
|
]
|
23
chat_box/migrations/0008_ignore.py
Normal file
23
chat_box/migrations/0008_ignore.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 2.2.17 on 2021-11-18 10:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('judge', '0116_auto_20211011_0645'),
|
||||||
|
('chat_box', '0007_auto_20211112_1255'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Ignore',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('ignored_users', models.ManyToManyField(to='judge.Profile')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ignored_chat_users', to='judge.Profile', verbose_name='user')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,5 +1,3 @@
|
||||||
from asgiref.sync import async_to_sync
|
|
||||||
from channels.layers import get_channel_layer
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import CASCADE
|
from django.db.models import CASCADE
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
@ -10,12 +8,23 @@ from judge.models.profile import Profile
|
||||||
|
|
||||||
__all__ = ['Message']
|
__all__ = ['Message']
|
||||||
|
|
||||||
|
class Room(models.Model):
|
||||||
|
user_one = models.ForeignKey(Profile, related_name="user_one", verbose_name='user 1', on_delete=CASCADE)
|
||||||
|
user_two = models.ForeignKey(Profile, related_name="user_two", verbose_name='user 2', on_delete=CASCADE)
|
||||||
|
|
||||||
|
def contain(self, profile):
|
||||||
|
return self.user_one == profile or self.user_two == profile
|
||||||
|
def other_user(self, profile):
|
||||||
|
return self.user_one if profile == self.user_two else self.user_two
|
||||||
|
def users(self):
|
||||||
|
return [self.user_one, self.user_two]
|
||||||
|
|
||||||
class Message(models.Model):
|
class Message(models.Model):
|
||||||
author = models.ForeignKey(Profile, verbose_name=_('user'), on_delete=CASCADE)
|
author = models.ForeignKey(Profile, verbose_name=_('user'), on_delete=CASCADE)
|
||||||
time = models.DateTimeField(verbose_name=_('posted time'), auto_now_add=True)
|
time = models.DateTimeField(verbose_name=_('posted time'), auto_now_add=True)
|
||||||
body = models.TextField(verbose_name=_('body of comment'), max_length=8192)
|
body = models.TextField(verbose_name=_('body of comment'), max_length=8192)
|
||||||
hidden = models.BooleanField(verbose_name='is hidden', default=False)
|
hidden = models.BooleanField(verbose_name='is hidden', default=False)
|
||||||
|
room = models.ForeignKey(Room, verbose_name='room id', on_delete=CASCADE, default=None, null=True)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
new_message = self.id
|
new_message = self.id
|
||||||
|
@ -27,3 +36,49 @@ class Message(models.Model):
|
||||||
verbose_name = 'message'
|
verbose_name = 'message'
|
||||||
verbose_name_plural = 'messages'
|
verbose_name_plural = 'messages'
|
||||||
ordering = ('-time',)
|
ordering = ('-time',)
|
||||||
|
|
||||||
|
class UserRoom(models.Model):
|
||||||
|
user = models.ForeignKey(Profile, verbose_name=_('user'), on_delete=CASCADE)
|
||||||
|
room = models.ForeignKey(Room, verbose_name='room id', on_delete=CASCADE, default=None, null=True)
|
||||||
|
last_seen = models.DateTimeField(verbose_name=_('last seen'), auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Ignore(models.Model):
|
||||||
|
user = models.ForeignKey(Profile, related_name="ignored_chat_users", verbose_name=_('user'), on_delete=CASCADE)
|
||||||
|
ignored_users = models.ManyToManyField(Profile)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_ignored(self, current_user, new_friend):
|
||||||
|
try:
|
||||||
|
return current_user.ignored_chat_users.get().ignored_users \
|
||||||
|
.filter(id=new_friend.id).exists()
|
||||||
|
except:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_ignored_users(self, user):
|
||||||
|
try:
|
||||||
|
return self.objects.get(user=user).ignored_users.all()
|
||||||
|
except Ignore.DoesNotExist:
|
||||||
|
return Profile.objects.none()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_ignore(self, current_user, friend):
|
||||||
|
ignore, created = self.objects.get_or_create(
|
||||||
|
user = current_user
|
||||||
|
)
|
||||||
|
ignore.ignored_users.add(friend)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def remove_ignore(self, current_user, friend):
|
||||||
|
ignore, created = self.objects.get_or_create(
|
||||||
|
user = current_user
|
||||||
|
)
|
||||||
|
ignore.ignored_users.remove(friend)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def toggle_ignore(self, current_user, friend):
|
||||||
|
if (self.is_ignored(current_user, friend)):
|
||||||
|
self.remove_ignore(current_user, friend)
|
||||||
|
else:
|
||||||
|
self.add_ignore(current_user, friend)
|
|
@ -1,8 +0,0 @@
|
||||||
from django.urls import re_path
|
|
||||||
|
|
||||||
from . import consumers
|
|
||||||
|
|
||||||
ASGI_APPLICATION = "chat_box.routing.application"
|
|
||||||
websocket_urlpatterns = [
|
|
||||||
re_path(r'ws/chat/', consumers.ChatConsumer),
|
|
||||||
]
|
|
18
chat_box/utils.py
Normal file
18
chat_box/utils.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
secret_key = settings.CHAT_SECRET_KEY
|
||||||
|
fernet = Fernet(secret_key)
|
||||||
|
|
||||||
|
def encrypt_url(creator_id, other_id):
|
||||||
|
message = str(creator_id) + '_' + str(other_id)
|
||||||
|
return fernet.encrypt(message.encode()).decode()
|
||||||
|
|
||||||
|
def decrypt_url(message_encrypted):
|
||||||
|
try:
|
||||||
|
dec_message = fernet.decrypt(message_encrypted.encode()).decode()
|
||||||
|
creator_id, other_id = dec_message.split('_')
|
||||||
|
return int(creator_id), int(other_id)
|
||||||
|
except Exception as e:
|
||||||
|
return None, None
|
|
@ -1,79 +1,99 @@
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from django.views.generic import ListView
|
from django.views.generic import ListView
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse, HttpResponseBadRequest, HttpResponsePermanentRedirect, HttpResponseRedirect
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.forms.models import model_to_dict
|
from django.forms.models import model_to_dict
|
||||||
|
from django.db.models import Case, BooleanField, When, Q, Subquery, OuterRef, Exists, Count, IntegerField
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from judge import event_poster as event
|
||||||
from judge.jinja2.gravatar import gravatar
|
from judge.jinja2.gravatar import gravatar
|
||||||
from .models import Message, Profile
|
from judge.models import Friend
|
||||||
|
|
||||||
|
from chat_box.models import Message, Profile, Room, UserRoom, Ignore
|
||||||
|
from chat_box.utils import encrypt_url, decrypt_url
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
def format_messages(messages):
|
|
||||||
msg_list = [{
|
|
||||||
'time': msg.time,
|
|
||||||
'author': msg.author,
|
|
||||||
'body': msg.body,
|
|
||||||
'image': gravatar(msg.author, 32),
|
|
||||||
'id': msg.id,
|
|
||||||
'css_class': msg.author.css_class,
|
|
||||||
} for msg in messages]
|
|
||||||
return json.dumps(msg_list, default=str)
|
|
||||||
|
|
||||||
def get_admin_online_status():
|
|
||||||
all_admin = Profile.objects.filter(display_rank='admin')
|
|
||||||
last_five_minutes = timezone.now()-timezone.timedelta(minutes=5)
|
|
||||||
ret = []
|
|
||||||
|
|
||||||
for admin in all_admin:
|
|
||||||
is_online = False
|
|
||||||
if (admin.last_access >= last_five_minutes):
|
|
||||||
is_online = True
|
|
||||||
ret.append({'user': admin, 'is_online': is_online})
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
class ChatView(ListView):
|
class ChatView(ListView):
|
||||||
model = Message
|
|
||||||
context_object_name = 'message'
|
context_object_name = 'message'
|
||||||
template_name = 'chat/chat.html'
|
template_name = 'chat/chat.html'
|
||||||
title = _('Chat Box')
|
title = _('Chat Box')
|
||||||
paginate_by = 50
|
|
||||||
paginator = Paginator(Message.objects.filter(hidden=False), paginate_by)
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.room_id = None
|
||||||
|
self.room = None
|
||||||
|
self.paginate_by = 50
|
||||||
|
self.messages = None
|
||||||
|
self.paginator = None
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Message.objects.filter(hidden=False)
|
return self.messages
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
request_room = kwargs['room_id']
|
||||||
page = request.GET.get('page')
|
page = request.GET.get('page')
|
||||||
if (page == None):
|
|
||||||
# return render(request, 'chat/chat.html', {'message': format_messages(Message.objects.all())})
|
if request_room:
|
||||||
|
try:
|
||||||
|
self.room = Room.objects.get(id=request_room)
|
||||||
|
if not can_access_room(request, self.room):
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
else:
|
||||||
|
request_room = None
|
||||||
|
|
||||||
|
if request_room != self.room_id or not self.messages:
|
||||||
|
self.room_id = request_room
|
||||||
|
self.messages = Message.objects.filter(hidden=False, room=self.room_id)
|
||||||
|
self.paginator = Paginator(self.messages, self.paginate_by)
|
||||||
|
|
||||||
|
if page == None:
|
||||||
|
update_last_seen(request, **kwargs)
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
cur_page = self.paginator.get_page(page)
|
cur_page = self.paginator.get_page(page)
|
||||||
return HttpResponse(format_messages(cur_page.object_list))
|
|
||||||
|
return render(request, 'chat/message_list.html', {
|
||||||
|
'object_list': cur_page.object_list,
|
||||||
|
'num_pages': self.paginator.num_pages
|
||||||
|
})
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
# hard code, should be fixed later
|
|
||||||
address = f'{self.request.get_host()}/ws/chat/'
|
|
||||||
if self.request.is_secure():
|
|
||||||
context['ws_address'] = f'wss://{address}'
|
|
||||||
else:
|
|
||||||
context['ws_address'] = f'ws://{address}'
|
|
||||||
|
|
||||||
context['title'] = self.title
|
context['title'] = self.title
|
||||||
last_five_minutes = timezone.now()-timezone.timedelta(minutes=5)
|
context['last_msg'] = event.last()
|
||||||
context['online_users'] = Profile.objects \
|
context['status_sections'] = get_status_context(self.request)
|
||||||
.filter(display_rank='user',
|
context['room'] = self.room_id
|
||||||
last_access__gte = last_five_minutes)\
|
context['unread_count_lobby'] = get_unread_count(None, self.request.profile)
|
||||||
.order_by('-rating')
|
if self.room:
|
||||||
context['admin_status'] = get_admin_online_status()
|
users_room = [self.room.user_one, self.room.user_two]
|
||||||
|
users_room.remove(self.request.profile)
|
||||||
|
context['other_user'] = users_room[0]
|
||||||
|
context['other_online'] = get_user_online_status(context['other_user'])
|
||||||
|
context['is_ignored'] = Ignore.is_ignored(self.request.profile, context['other_user'])
|
||||||
|
else:
|
||||||
|
context['online_count'] = get_online_count()
|
||||||
|
context['message_template'] = {
|
||||||
|
'author': self.request.profile,
|
||||||
|
'id': '$id',
|
||||||
|
'time': timezone.now(),
|
||||||
|
'body': '$body'
|
||||||
|
}
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
def delete_message(request):
|
def delete_message(request):
|
||||||
ret = {'delete': 'done'}
|
ret = {'delete': 'done'}
|
||||||
|
|
||||||
|
@ -81,17 +101,352 @@ def delete_message(request):
|
||||||
return JsonResponse(ret)
|
return JsonResponse(ret)
|
||||||
|
|
||||||
if request.user.is_staff:
|
if request.user.is_staff:
|
||||||
messid = int(request.POST.get('messid'))
|
try:
|
||||||
all_mess = Message.objects.all()
|
messid = int(request.POST.get('message'))
|
||||||
|
mess = Message.objects.get(id=messid)
|
||||||
|
except:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
for mess in all_mess:
|
mess.hidden = True
|
||||||
if mess.id == messid:
|
mess.save()
|
||||||
mess.hidden = True
|
|
||||||
mess.save()
|
|
||||||
new_elt = {'time': mess.time, 'content': mess.body}
|
|
||||||
ret = new_elt
|
|
||||||
break
|
|
||||||
|
|
||||||
return JsonResponse(ret)
|
return JsonResponse(ret)
|
||||||
|
|
||||||
return JsonResponse(ret)
|
return JsonResponse(ret)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def post_message(request):
|
||||||
|
ret = {'msg': 'posted'}
|
||||||
|
if request.method != 'POST':
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
room = None
|
||||||
|
if request.POST['room']:
|
||||||
|
room = Room.objects.get(id=request.POST['room'])
|
||||||
|
|
||||||
|
if not can_access_room(request, room) or request.profile.mute:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
new_message = Message(author=request.profile,
|
||||||
|
body=request.POST['body'],
|
||||||
|
room=room)
|
||||||
|
new_message.save()
|
||||||
|
|
||||||
|
if not room:
|
||||||
|
event.post('chat_lobby', {
|
||||||
|
'type': 'lobby',
|
||||||
|
'author_id': request.profile.id,
|
||||||
|
'message': new_message.id,
|
||||||
|
'room': 'None',
|
||||||
|
'tmp_id': request.POST.get('tmp_id')
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
for user in room.users():
|
||||||
|
event.post('chat_' + str(user.id), {
|
||||||
|
'type': 'private',
|
||||||
|
'author_id': request.profile.id,
|
||||||
|
'message': new_message.id,
|
||||||
|
'room': room.id,
|
||||||
|
'tmp_id': request.POST.get('tmp_id')
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse(ret)
|
||||||
|
|
||||||
|
|
||||||
|
def can_access_room(request, room):
|
||||||
|
return not room or room.user_one == request.profile or room.user_two == request.profile
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def chat_message_ajax(request):
|
||||||
|
if request.method != 'GET':
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message_id = request.GET['message']
|
||||||
|
except KeyError:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
message = Message.objects.filter(hidden=False).get(id=message_id)
|
||||||
|
room = message.room
|
||||||
|
if room and not room.contain(request.profile):
|
||||||
|
return HttpResponse('Unauthorized', status=401)
|
||||||
|
except Message.DoesNotExist:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
return render(request, 'chat/message.html', {
|
||||||
|
'message': message,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def update_last_seen(request, **kwargs):
|
||||||
|
if 'room_id' in kwargs:
|
||||||
|
room_id = kwargs['room_id']
|
||||||
|
elif request.method == 'GET':
|
||||||
|
room_id = request.GET.get('room')
|
||||||
|
elif request.method == 'POST':
|
||||||
|
room_id = request.POST.get('room')
|
||||||
|
else:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
profile = request.profile
|
||||||
|
room = None
|
||||||
|
if room_id:
|
||||||
|
room = Room.objects.get(id=int(room_id))
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
except Exception as e:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
if room and not room.contain(profile):
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
user_room, _ = UserRoom.objects.get_or_create(user=profile, room=room)
|
||||||
|
user_room.last_seen = timezone.now()
|
||||||
|
user_room.save()
|
||||||
|
|
||||||
|
return JsonResponse({'msg': 'updated'})
|
||||||
|
|
||||||
|
|
||||||
|
def get_online_count():
|
||||||
|
last_two_minutes = timezone.now()-timezone.timedelta(minutes=2)
|
||||||
|
return Profile.objects.filter(last_access__gte=last_two_minutes).count()
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_online_status(user):
|
||||||
|
time_diff = timezone.now() - user.last_access
|
||||||
|
is_online = time_diff <= timezone.timedelta(minutes=2)
|
||||||
|
return is_online
|
||||||
|
|
||||||
|
|
||||||
|
def user_online_status_ajax(request):
|
||||||
|
if request.method != 'GET':
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
user_id = request.GET.get('user')
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
user_id = int(user_id)
|
||||||
|
user = Profile.objects.get(id=user_id)
|
||||||
|
except Exception as e:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
is_online = get_user_online_status(user)
|
||||||
|
return render(request, 'chat/user_online_status.html', {
|
||||||
|
'other_user': user,
|
||||||
|
'other_online': is_online,
|
||||||
|
'is_ignored': Ignore.is_ignored(request.profile, user)
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return render(request, 'chat/user_online_status.html', {
|
||||||
|
'online_count': get_online_count(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def get_online_status(request_user, queryset, rooms=None):
|
||||||
|
if not queryset:
|
||||||
|
return None
|
||||||
|
last_two_minutes = timezone.now()-timezone.timedelta(minutes=2)
|
||||||
|
ret = []
|
||||||
|
|
||||||
|
if rooms:
|
||||||
|
unread_count = get_unread_count(rooms, request_user)
|
||||||
|
count = {}
|
||||||
|
for i in unread_count:
|
||||||
|
count[i['other_user']] = i['unread_count']
|
||||||
|
|
||||||
|
for user in queryset:
|
||||||
|
is_online = False
|
||||||
|
if (user.last_access >= last_two_minutes):
|
||||||
|
is_online = True
|
||||||
|
user_dict = {'user': user, 'is_online': is_online}
|
||||||
|
if rooms and user.id in count:
|
||||||
|
user_dict['unread_count'] = count[user.id]
|
||||||
|
user_dict['url'] = encrypt_url(request_user.id, user.id)
|
||||||
|
ret.append(user_dict)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def get_status_context(request, include_ignored=False):
|
||||||
|
if include_ignored:
|
||||||
|
ignored_users = Profile.objects.none()
|
||||||
|
queryset = Profile.objects
|
||||||
|
else:
|
||||||
|
ignored_users = Ignore.get_ignored_users(request.profile)
|
||||||
|
queryset = Profile.objects.exclude(id__in=ignored_users)
|
||||||
|
|
||||||
|
last_two_minutes = timezone.now()-timezone.timedelta(minutes=2)
|
||||||
|
recent_profile = Room.objects.filter(
|
||||||
|
Q(user_one=request.profile) | Q(user_two=request.profile)
|
||||||
|
).annotate(
|
||||||
|
last_msg_time=Subquery(
|
||||||
|
Message.objects.filter(room=OuterRef('pk')).values('time')[:1]
|
||||||
|
),
|
||||||
|
other_user=Case(
|
||||||
|
When(user_one=request.profile, then='user_two'),
|
||||||
|
default='user_one',
|
||||||
|
)
|
||||||
|
).filter(last_msg_time__isnull=False)\
|
||||||
|
.exclude(other_user__in=ignored_users)\
|
||||||
|
.order_by('-last_msg_time').values('other_user', 'id')[:20]
|
||||||
|
|
||||||
|
recent_profile_id = [str(i['other_user']) for i in recent_profile]
|
||||||
|
joined_id = ','.join(recent_profile_id)
|
||||||
|
recent_rooms = [int(i['id']) for i in recent_profile]
|
||||||
|
recent_list = None
|
||||||
|
if joined_id:
|
||||||
|
recent_list = Profile.objects.raw(
|
||||||
|
f'SELECT * from judge_profile where id in ({joined_id}) order by field(id,{joined_id})')
|
||||||
|
friend_list = Friend.get_friend_profiles(request.profile).exclude(id__in=recent_profile_id)\
|
||||||
|
.exclude(id__in=ignored_users)\
|
||||||
|
.order_by('-last_access')
|
||||||
|
admin_list = queryset.filter(display_rank='admin')\
|
||||||
|
.exclude(id__in=friend_list).exclude(id__in=recent_profile_id)
|
||||||
|
all_user_status = queryset\
|
||||||
|
.filter(display_rank='user',
|
||||||
|
last_access__gte = last_two_minutes)\
|
||||||
|
.annotate(is_online=Case(default=True,output_field=BooleanField()))\
|
||||||
|
.order_by('-rating').exclude(id__in=friend_list).exclude(id__in=admin_list)\
|
||||||
|
.exclude(id__in=recent_profile_id)[:30]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'title': 'Recent',
|
||||||
|
'user_list': get_online_status(request.profile, recent_list, recent_rooms),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Following',
|
||||||
|
'user_list': get_online_status(request.profile, friend_list),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Admin',
|
||||||
|
'user_list': get_online_status(request.profile, admin_list),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'Other',
|
||||||
|
'user_list': get_online_status(request.profile, all_user_status),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def online_status_ajax(request):
|
||||||
|
return render(request, 'chat/online_status.html', {
|
||||||
|
'status_sections': get_status_context(request),
|
||||||
|
'unread_count_lobby': get_unread_count(None, request.profile),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def get_room(user_one, user_two):
|
||||||
|
if user_one.id > user_two.id:
|
||||||
|
user_one, user_two = user_two, user_one
|
||||||
|
room, created = Room.objects.get_or_create(user_one=user_one, user_two=user_two)
|
||||||
|
return room
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def get_or_create_room(request):
|
||||||
|
if request.method == 'GET':
|
||||||
|
decrypted_other_id = request.GET.get('other')
|
||||||
|
elif request.method == 'POST':
|
||||||
|
decrypted_other_id = request.POST.get('other')
|
||||||
|
else:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
request_id, other_id = decrypt_url(decrypted_other_id)
|
||||||
|
|
||||||
|
if not other_id or not request_id or request_id != request.profile.id:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
other_user = Profile.objects.get(id=int(other_id))
|
||||||
|
except Exception:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
user = request.profile
|
||||||
|
|
||||||
|
if not other_user or not user:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
# TODO: each user can only create <= 300 rooms
|
||||||
|
room = get_room(other_user, user)
|
||||||
|
for u in [other_user, user]:
|
||||||
|
user_room, _ = UserRoom.objects.get_or_create(user=u, room=room)
|
||||||
|
user_room.last_seen = timezone.now()
|
||||||
|
user_room.save()
|
||||||
|
|
||||||
|
if request.method == 'GET':
|
||||||
|
return JsonResponse({'room': room.id, 'other_user_id': other_user.id})
|
||||||
|
return HttpResponseRedirect(reverse('chat', kwargs={'room_id': room.id}))
|
||||||
|
|
||||||
|
|
||||||
|
def get_unread_count(rooms, user):
|
||||||
|
if rooms:
|
||||||
|
mess = Message.objects.filter(room=OuterRef('room'),
|
||||||
|
time__gte=OuterRef('last_seen'))\
|
||||||
|
.exclude(author=user)\
|
||||||
|
.order_by().values('room')\
|
||||||
|
.annotate(unread_count=Count('pk')).values('unread_count')
|
||||||
|
|
||||||
|
return UserRoom.objects\
|
||||||
|
.filter(user=user, room__in=rooms)\
|
||||||
|
.annotate(
|
||||||
|
unread_count=Coalesce(Subquery(mess, output_field=IntegerField()), 0),
|
||||||
|
other_user=Case(
|
||||||
|
When(room__user_one=user, then='room__user_two'),
|
||||||
|
default='room__user_one',
|
||||||
|
)
|
||||||
|
).filter(unread_count__gte=1).values('other_user', 'unread_count')
|
||||||
|
else: # lobby
|
||||||
|
mess = Message.objects.filter(room__isnull=True,
|
||||||
|
time__gte=OuterRef('last_seen'))\
|
||||||
|
.exclude(author=user)\
|
||||||
|
.order_by().values('room')\
|
||||||
|
.annotate(unread_count=Count('pk')).values('unread_count')
|
||||||
|
|
||||||
|
res = UserRoom.objects\
|
||||||
|
.filter(user=user, room__isnull=True)\
|
||||||
|
.annotate(
|
||||||
|
unread_count=Coalesce(Subquery(mess, output_field=IntegerField()), 0),
|
||||||
|
).values_list('unread_count', flat=True)
|
||||||
|
|
||||||
|
return res[0] if len(res) else 0
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def toggle_ignore(request, **kwargs):
|
||||||
|
user_id = kwargs['user_id']
|
||||||
|
if not user_id:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
try:
|
||||||
|
other_user = Profile.objects.get(id=user_id)
|
||||||
|
except:
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
Ignore.toggle_ignore(request.profile, other_user)
|
||||||
|
next_url = request.GET.get('next', '/')
|
||||||
|
return HttpResponseRedirect(next_url)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def get_unread_boxes(request):
|
||||||
|
if (request.method != 'GET'):
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
mess = Message.objects.filter(room=OuterRef('room'),
|
||||||
|
time__gte=OuterRef('last_seen'))\
|
||||||
|
.exclude(author=request.profile)\
|
||||||
|
.order_by().values('room')\
|
||||||
|
.annotate(unread_count=Count('pk')).values('unread_count')
|
||||||
|
|
||||||
|
unread_boxes = UserRoom.objects\
|
||||||
|
.filter(user=request.profile, room__isnull=False)\
|
||||||
|
.annotate(
|
||||||
|
unread_count=Coalesce(Subquery(mess, output_field=IntegerField()), 0),
|
||||||
|
).filter(unread_count__gte=1).count()
|
||||||
|
|
||||||
|
return JsonResponse({'unread_boxes': unread_boxes})
|
|
@ -1,12 +0,0 @@
|
||||||
from channels.auth import AuthMiddlewareStack
|
|
||||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
|
||||||
import chat_box.routing
|
|
||||||
|
|
||||||
application = ProtocolTypeRouter({
|
|
||||||
# (http->django views is added by default)
|
|
||||||
'websocket': AuthMiddlewareStack(
|
|
||||||
URLRouter(
|
|
||||||
chat_box.routing.websocket_urlpatterns
|
|
||||||
)
|
|
||||||
),
|
|
||||||
})
|
|
|
@ -72,7 +72,7 @@ DMOJ_BLOG_NEW_PROBLEM_COUNT = 7
|
||||||
DMOJ_BLOG_NEW_CONTEST_COUNT = 7
|
DMOJ_BLOG_NEW_CONTEST_COUNT = 7
|
||||||
DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT = 7
|
DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT = 7
|
||||||
DMOJ_TOTP_TOLERANCE_HALF_MINUTES = 1
|
DMOJ_TOTP_TOLERANCE_HALF_MINUTES = 1
|
||||||
DMOJ_USER_MAX_ORGANIZATION_COUNT = 3
|
DMOJ_USER_MAX_ORGANIZATION_COUNT = 10
|
||||||
DMOJ_COMMENT_VOTE_HIDE_THRESHOLD = -5
|
DMOJ_COMMENT_VOTE_HIDE_THRESHOLD = -5
|
||||||
DMOJ_PDF_PROBLEM_CACHE = ''
|
DMOJ_PDF_PROBLEM_CACHE = ''
|
||||||
DMOJ_PDF_PROBLEM_TEMP_DIR = tempfile.gettempdir()
|
DMOJ_PDF_PROBLEM_TEMP_DIR = tempfile.gettempdir()
|
||||||
|
@ -125,6 +125,10 @@ SLIMERJS_PAPER_SIZE = 'Letter'
|
||||||
PUPPETEER_MODULE = '/usr/lib/node_modules/puppeteer'
|
PUPPETEER_MODULE = '/usr/lib/node_modules/puppeteer'
|
||||||
PUPPETEER_PAPER_SIZE = 'Letter'
|
PUPPETEER_PAPER_SIZE = 'Letter'
|
||||||
|
|
||||||
|
USE_SELENIUM = False
|
||||||
|
SELENIUM_CUSTOM_CHROME_PATH = None
|
||||||
|
SELENIUM_CHROMEDRIVER_PATH = 'chromedriver'
|
||||||
|
|
||||||
PYGMENT_THEME = 'pygment-github.css'
|
PYGMENT_THEME = 'pygment-github.css'
|
||||||
INLINE_JQUERY = True
|
INLINE_JQUERY = True
|
||||||
INLINE_FONTAWESOME = True
|
INLINE_FONTAWESOME = True
|
||||||
|
@ -239,8 +243,8 @@ INSTALLED_APPS += (
|
||||||
'impersonate',
|
'impersonate',
|
||||||
'django_jinja',
|
'django_jinja',
|
||||||
'chat_box',
|
'chat_box',
|
||||||
'channels',
|
|
||||||
'newsletter',
|
'newsletter',
|
||||||
|
'django.forms',
|
||||||
)
|
)
|
||||||
|
|
||||||
MIDDLEWARE = (
|
MIDDLEWARE = (
|
||||||
|
@ -263,6 +267,8 @@ MIDDLEWARE = (
|
||||||
'django.contrib.redirects.middleware.RedirectFallbackMiddleware',
|
'django.contrib.redirects.middleware.RedirectFallbackMiddleware',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
|
||||||
|
|
||||||
IMPERSONATE_REQUIRE_SUPERUSER = True
|
IMPERSONATE_REQUIRE_SUPERUSER = True
|
||||||
IMPERSONATE_DISABLE_LOGGING = True
|
IMPERSONATE_DISABLE_LOGGING = True
|
||||||
|
|
||||||
|
@ -484,6 +490,8 @@ SOCIAL_AUTH_PIPELINE = (
|
||||||
'social_core.pipeline.user.user_details',
|
'social_core.pipeline.user.user_details',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SOCIAL_AUTH_PROTECTED_USER_FIELDS = ['first_name', 'last_name']
|
||||||
|
SOCIAL_AUTH_GOOGLE_OAUTH2_USER_FIELDS = ['email', 'username']
|
||||||
SOCIAL_AUTH_GITHUB_SECURE_SCOPE = ['user:email']
|
SOCIAL_AUTH_GITHUB_SECURE_SCOPE = ['user:email']
|
||||||
SOCIAL_AUTH_FACEBOOK_SCOPE = ['email']
|
SOCIAL_AUTH_FACEBOOK_SCOPE = ['email']
|
||||||
SOCIAL_AUTH_SLUGIFY_USERNAMES = True
|
SOCIAL_AUTH_SLUGIFY_USERNAMES = True
|
||||||
|
@ -495,11 +503,6 @@ MOSS_API_KEY = None
|
||||||
|
|
||||||
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
CELERY_WORKER_HIJACK_ROOT_LOGGER = False
|
||||||
|
|
||||||
try:
|
|
||||||
with open(os.path.join(os.path.dirname(__file__), 'local_settings.py')) as f:
|
|
||||||
exec(f.read(), globals())
|
|
||||||
except IOError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
TESTCASE_VISIBLE_LENGTH = 64
|
TESTCASE_VISIBLE_LENGTH = 64
|
||||||
|
|
||||||
|
@ -509,17 +512,6 @@ FILE_UPLOAD_PERMISSIONS = 0o644
|
||||||
|
|
||||||
MESSAGES_TO_LOAD = 15
|
MESSAGES_TO_LOAD = 15
|
||||||
|
|
||||||
ASGI_APPLICATION = 'dmoj.routing.application'
|
|
||||||
CHANNEL_LAYERS = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'channels_redis.core.RedisChannelLayer',
|
|
||||||
'CONFIG': {
|
|
||||||
"hosts": [('0.0.0.0', 6379)],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
NEWSLETTER_CONFIRM_EMAIL = False
|
NEWSLETTER_CONFIRM_EMAIL = False
|
||||||
|
|
||||||
# Amount of seconds to wait between each email. Here 100ms is used.
|
# Amount of seconds to wait between each email. Here 100ms is used.
|
||||||
|
@ -530,3 +522,12 @@ NEWSLETTER_BATCH_DELAY = 60
|
||||||
|
|
||||||
# Number of emails in one batch
|
# Number of emails in one batch
|
||||||
NEWSLETTER_BATCH_SIZE = 100
|
NEWSLETTER_BATCH_SIZE = 100
|
||||||
|
|
||||||
|
# Google form to request name
|
||||||
|
REGISTER_NAME_URL = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(os.path.join(os.path.dirname(__file__), 'local_settings.py')) as f:
|
||||||
|
exec(f.read(), globals())
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
|
41
dmoj/urls.py
41
dmoj/urls.py
|
@ -1,4 +1,5 @@
|
||||||
from chat_box.views import ChatView, delete_message
|
from chat_box.views import *
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
@ -21,10 +22,10 @@ from judge.views import TitledTemplateView, about, api, blog, comment, contests,
|
||||||
notification, organization, preview, problem, problem_manage, ranked_submission, register, stats, status, submission, tasks, \
|
notification, organization, preview, problem, problem_manage, ranked_submission, register, stats, status, submission, tasks, \
|
||||||
ticket, totp, user, widgets
|
ticket, totp, user, widgets
|
||||||
from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \
|
from judge.views.problem_data import ProblemDataView, ProblemSubmissionDiff, \
|
||||||
problem_data_file, problem_init_view
|
problem_data_file, problem_init_view, ProblemZipUploadView
|
||||||
from judge.views.register import ActivationView, RegistrationView
|
from judge.views.register import ActivationView, RegistrationView
|
||||||
from judge.views.select2 import AssigneeSelect2View, CommentSelect2View, ContestSelect2View, \
|
from judge.views.select2 import AssigneeSelect2View, ChatUserSearchSelect2View, CommentSelect2View, \
|
||||||
ContestUserSearchSelect2View, OrganizationSelect2View, ProblemSelect2View, TicketUserSelect2View, \
|
ContestSelect2View, ContestUserSearchSelect2View, OrganizationSelect2View, ProblemSelect2View, TicketUserSelect2View, \
|
||||||
UserSearchSelect2View, UserSelect2View
|
UserSearchSelect2View, UserSelect2View
|
||||||
|
|
||||||
admin.autodiscover()
|
admin.autodiscover()
|
||||||
|
@ -115,6 +116,7 @@ urlpatterns = [
|
||||||
url(r'^problem/(?P<problem>[^/]+)', include([
|
url(r'^problem/(?P<problem>[^/]+)', include([
|
||||||
url(r'^$', problem.ProblemDetail.as_view(), name='problem_detail'),
|
url(r'^$', problem.ProblemDetail.as_view(), name='problem_detail'),
|
||||||
url(r'^/editorial$', problem.ProblemSolution.as_view(), name='problem_editorial'),
|
url(r'^/editorial$', problem.ProblemSolution.as_view(), name='problem_editorial'),
|
||||||
|
url(r'^/comments$', problem.ProblemComments.as_view(), name='problem_comments'),
|
||||||
url(r'^/raw$', problem.ProblemRaw.as_view(), name='problem_raw'),
|
url(r'^/raw$', problem.ProblemRaw.as_view(), name='problem_raw'),
|
||||||
url(r'^/pdf$', problem.ProblemPdfView.as_view(), name='problem_pdf'),
|
url(r'^/pdf$', problem.ProblemPdfView.as_view(), name='problem_pdf'),
|
||||||
url(r'^/pdf/(?P<language>[a-z-]+)$', problem.ProblemPdfView.as_view(), name='problem_pdf'),
|
url(r'^/pdf/(?P<language>[a-z-]+)$', problem.ProblemPdfView.as_view(), name='problem_pdf'),
|
||||||
|
@ -131,6 +133,7 @@ urlpatterns = [
|
||||||
url(r'^/test_data$', ProblemDataView.as_view(), name='problem_data'),
|
url(r'^/test_data$', ProblemDataView.as_view(), name='problem_data'),
|
||||||
url(r'^/test_data/init$', problem_init_view, name='problem_data_init'),
|
url(r'^/test_data/init$', problem_init_view, name='problem_data_init'),
|
||||||
url(r'^/test_data/diff$', ProblemSubmissionDiff.as_view(), name='problem_submission_diff'),
|
url(r'^/test_data/diff$', ProblemSubmissionDiff.as_view(), name='problem_submission_diff'),
|
||||||
|
url(r'^/test_data/upload$', ProblemZipUploadView.as_view(), name='problem_zip_upload'),
|
||||||
url(r'^/data/(?P<path>.+)$', problem_data_file, name='problem_data_file'),
|
url(r'^/data/(?P<path>.+)$', problem_data_file, name='problem_data_file'),
|
||||||
|
|
||||||
url(r'^/tickets$', ticket.ProblemTicketListView.as_view(), name='problem_ticket_list'),
|
url(r'^/tickets$', ticket.ProblemTicketListView.as_view(), name='problem_ticket_list'),
|
||||||
|
@ -225,6 +228,9 @@ urlpatterns = [
|
||||||
url(r'^/participation/disqualify$', contests.ContestParticipationDisqualify.as_view(),
|
url(r'^/participation/disqualify$', contests.ContestParticipationDisqualify.as_view(),
|
||||||
name='contest_participation_disqualify'),
|
name='contest_participation_disqualify'),
|
||||||
|
|
||||||
|
url(r'^/clarification$', contests.NewContestClarificationView.as_view(), name='new_contest_clarification'),
|
||||||
|
url(r'^/clarification/ajax$', contests.ContestClarificationAjax.as_view(), name='contest_clarification_ajax'),
|
||||||
|
|
||||||
url(r'^/$', lambda _, contest: HttpResponsePermanentRedirect(reverse('contest_view', args=[contest]))),
|
url(r'^/$', lambda _, contest: HttpResponsePermanentRedirect(reverse('contest_view', args=[contest]))),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
|
@ -284,6 +290,7 @@ urlpatterns = [
|
||||||
|
|
||||||
url(r'^select2/', include([
|
url(r'^select2/', include([
|
||||||
url(r'^user_search$', UserSearchSelect2View.as_view(), name='user_search_select2_ajax'),
|
url(r'^user_search$', UserSearchSelect2View.as_view(), name='user_search_select2_ajax'),
|
||||||
|
url(r'^user_search_chat$', ChatUserSearchSelect2View.as_view(), name='chat_user_search_select2_ajax'),
|
||||||
url(r'^contest_users/(?P<contest>\w+)$', ContestUserSearchSelect2View.as_view(),
|
url(r'^contest_users/(?P<contest>\w+)$', ContestUserSearchSelect2View.as_view(),
|
||||||
name='contest_user_search_select2_ajax'),
|
name='contest_user_search_select2_ajax'),
|
||||||
url(r'^ticket_user$', TicketUserSelect2View.as_view(), name='ticket_user_select2_ajax'),
|
url(r'^ticket_user$', TicketUserSelect2View.as_view(), name='ticket_user_select2_ajax'),
|
||||||
|
@ -369,16 +376,28 @@ urlpatterns = [
|
||||||
url(r'^custom_checker_sample/', about.custom_checker_sample, name='custom_checker_sample'),
|
url(r'^custom_checker_sample/', about.custom_checker_sample, name='custom_checker_sample'),
|
||||||
|
|
||||||
url(r'^chat/', include([
|
url(r'^chat/', include([
|
||||||
url(r'^$',
|
url(r'^(?P<room_id>\d*)$', login_required(ChatView.as_view()), name='chat'),
|
||||||
login_required(ChatView.as_view()),
|
url(r'^delete/$', delete_message, name='delete_chat_message'),
|
||||||
name='chat'),
|
url(r'^post/$', post_message, name='post_chat_message'),
|
||||||
url(r'^delete/$', delete_message, name='delete_message')
|
url(r'^ajax$', chat_message_ajax, name='chat_message_ajax'),
|
||||||
|
url(r'^online_status/ajax$', online_status_ajax, name='online_status_ajax'),
|
||||||
|
url(r'^get_or_create_room$', get_or_create_room, name='get_or_create_room'),
|
||||||
|
url(r'^update_last_seen$', update_last_seen, name='update_last_seen'),
|
||||||
|
url(r'^online_status/user/ajax$', user_online_status_ajax, name='user_online_status_ajax'),
|
||||||
|
url(r'^toggle_ignore/(?P<user_id>\d+)$', toggle_ignore, name='toggle_ignore'),
|
||||||
|
url(r'^get_unread_boxes$', get_unread_boxes, name='get_unread_boxes'),
|
||||||
])),
|
])),
|
||||||
|
|
||||||
url(r'^notifications/',
|
url(r'^notifications/',
|
||||||
login_required(notification.NotificationList.as_view()),
|
login_required(notification.NotificationList.as_view()),
|
||||||
name='notification')
|
name='notification'),
|
||||||
|
|
||||||
|
url(r'^import_users/', include([
|
||||||
|
url(r'^$', user.ImportUsersView.as_view(), name='import_users'),
|
||||||
|
url(r'post_file/$', user.import_users_post_file, name='import_users_post_file'),
|
||||||
|
url(r'submit/$', user.import_users_submit, name='import_users_submit'),
|
||||||
|
url(r'sample/$', user.sample_import_users, name='import_users_sample')
|
||||||
|
])),
|
||||||
]
|
]
|
||||||
|
|
||||||
favicon_paths = ['apple-touch-icon-180x180.png', 'apple-touch-icon-114x114.png', 'android-chrome-72x72.png',
|
favicon_paths = ['apple-touch-icon-180x180.png', 'apple-touch-icon-114x114.png', 'android-chrome-72x72.png',
|
||||||
|
@ -389,7 +408,7 @@ favicon_paths = ['apple-touch-icon-180x180.png', 'apple-touch-icon-114x114.png',
|
||||||
'favicon-96x96.png',
|
'favicon-96x96.png',
|
||||||
'favicon-32x32.png', 'favicon-16x16.png', 'android-chrome-192x192.png', 'android-chrome-48x48.png',
|
'favicon-32x32.png', 'favicon-16x16.png', 'android-chrome-192x192.png', 'android-chrome-48x48.png',
|
||||||
'mstile-310x150.png', 'apple-touch-icon-144x144.png', 'browserconfig.xml', 'manifest.json',
|
'mstile-310x150.png', 'apple-touch-icon-144x144.png', 'browserconfig.xml', 'manifest.json',
|
||||||
'apple-touch-icon-120x120.png', 'mstile-310x310.png']
|
'apple-touch-icon-120x120.png', 'mstile-310x310.png', 'reload.png']
|
||||||
|
|
||||||
for favicon in favicon_paths:
|
for favicon in favicon_paths:
|
||||||
urlpatterns.append(url(r'^%s$' % favicon, RedirectView.as_view(
|
urlpatterns.append(url(r'^%s$' % favicon, RedirectView.as_view(
|
||||||
|
|
|
@ -7,10 +7,12 @@ from django.forms import ModelForm, ModelMultipleChoiceField
|
||||||
from django.http import Http404, HttpResponseRedirect
|
from django.http import Http404, HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.urls import reverse, reverse_lazy
|
from django.urls import reverse, reverse_lazy
|
||||||
|
from django.utils import timezone
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.translation import gettext_lazy as _, ungettext
|
from django.utils.translation import gettext_lazy as _, ungettext
|
||||||
from reversion.admin import VersionAdmin
|
from reversion.admin import VersionAdmin
|
||||||
|
|
||||||
|
from django_ace import AceWidget
|
||||||
from judge.models import Contest, ContestProblem, ContestSubmission, Profile, Rating
|
from judge.models import Contest, ContestProblem, ContestSubmission, Profile, Rating
|
||||||
from judge.ratings import rate_contest
|
from judge.ratings import rate_contest
|
||||||
from judge.widgets import AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, AdminPagedownWidget, \
|
from judge.widgets import AdminHeavySelect2MultipleWidget, AdminHeavySelect2Widget, AdminPagedownWidget, \
|
||||||
|
@ -94,7 +96,9 @@ class ContestForm(ModelForm):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
widgets = {
|
widgets = {
|
||||||
'organizers': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
|
'authors': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
|
||||||
|
'curators': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
|
||||||
|
'testers': AdminHeavySelect2MultipleWidget(data_view='profile_select2'),
|
||||||
'private_contestants': AdminHeavySelect2MultipleWidget(data_view='profile_select2',
|
'private_contestants': AdminHeavySelect2MultipleWidget(data_view='profile_select2',
|
||||||
attrs={'style': 'width: 100%'}),
|
attrs={'style': 'width: 100%'}),
|
||||||
'organizations': AdminHeavySelect2MultipleWidget(data_view='organization_select2'),
|
'organizations': AdminHeavySelect2MultipleWidget(data_view='organization_select2'),
|
||||||
|
@ -111,18 +115,19 @@ class ContestForm(ModelForm):
|
||||||
|
|
||||||
class ContestAdmin(VersionAdmin):
|
class ContestAdmin(VersionAdmin):
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {'fields': ('key', 'name', 'organizers')}),
|
(None, {'fields': ('key', 'name', 'authors', 'curators', 'testers')}),
|
||||||
(_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'hide_problem_tags', 'hide_scoreboard',
|
(_('Settings'), {'fields': ('is_visible', 'use_clarifications', 'hide_problem_tags', 'scoreboard_visibility',
|
||||||
'run_pretests_only', 'points_precision')}),
|
'run_pretests_only', 'points_precision')}),
|
||||||
(_('Scheduling'), {'fields': ('start_time', 'end_time', 'time_limit')}),
|
(_('Scheduling'), {'fields': ('start_time', 'end_time', 'time_limit')}),
|
||||||
(_('Details'), {'fields': ('description', 'og_image', 'logo_override_image', 'tags', 'summary')}),
|
(_('Details'), {'fields': ('description', 'og_image', 'logo_override_image', 'tags', 'summary')}),
|
||||||
(_('Format'), {'fields': ('format_name', 'format_config')}),
|
(_('Format'), {'fields': ('format_name', 'format_config', 'problem_label_script')}),
|
||||||
(_('Rating'), {'fields': ('is_rated', 'rate_all', 'rating_floor', 'rating_ceiling', 'rate_exclude')}),
|
(_('Rating'), {'fields': ('is_rated', 'rate_all', 'rating_floor', 'rating_ceiling', 'rate_exclude')}),
|
||||||
(_('Access'), {'fields': ('access_code', 'is_private', 'private_contestants', 'is_organization_private',
|
(_('Access'), {'fields': ('access_code', 'is_private', 'private_contestants', 'is_organization_private',
|
||||||
'organizations', 'view_contest_scoreboard')}),
|
'organizations', 'view_contest_scoreboard')}),
|
||||||
(_('Justice'), {'fields': ('banned_users',)}),
|
(_('Justice'), {'fields': ('banned_users',)}),
|
||||||
)
|
)
|
||||||
list_display = ('key', 'name', 'is_visible', 'is_rated', 'start_time', 'end_time', 'time_limit', 'user_count')
|
list_display = ('key', 'name', 'is_visible', 'is_rated', 'start_time', 'end_time', 'time_limit', 'user_count')
|
||||||
|
search_fields = ('key', 'name')
|
||||||
inlines = [ContestProblemInline]
|
inlines = [ContestProblemInline]
|
||||||
actions_on_top = True
|
actions_on_top = True
|
||||||
actions_on_bottom = True
|
actions_on_bottom = True
|
||||||
|
@ -146,7 +151,7 @@ class ContestAdmin(VersionAdmin):
|
||||||
if request.user.has_perm('judge.edit_all_contest'):
|
if request.user.has_perm('judge.edit_all_contest'):
|
||||||
return queryset
|
return queryset
|
||||||
else:
|
else:
|
||||||
return queryset.filter(organizers__id=request.profile.id)
|
return queryset.filter(Q(authors=request.profile) | Q(curators=request.profile)).distinct()
|
||||||
|
|
||||||
def get_readonly_fields(self, request, obj=None):
|
def get_readonly_fields(self, request, obj=None):
|
||||||
readonly = []
|
readonly = []
|
||||||
|
@ -158,6 +163,8 @@ class ContestAdmin(VersionAdmin):
|
||||||
readonly += ['is_private', 'private_contestants', 'is_organization_private', 'organizations']
|
readonly += ['is_private', 'private_contestants', 'is_organization_private', 'organizations']
|
||||||
if not request.user.has_perm('judge.change_contest_visibility'):
|
if not request.user.has_perm('judge.change_contest_visibility'):
|
||||||
readonly += ['is_visible']
|
readonly += ['is_visible']
|
||||||
|
if not request.user.has_perm('judge.contest_problem_label'):
|
||||||
|
readonly += ['problem_label_script']
|
||||||
return readonly
|
return readonly
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
|
@ -185,9 +192,9 @@ class ContestAdmin(VersionAdmin):
|
||||||
def has_change_permission(self, request, obj=None):
|
def has_change_permission(self, request, obj=None):
|
||||||
if not request.user.has_perm('judge.edit_own_contest'):
|
if not request.user.has_perm('judge.edit_own_contest'):
|
||||||
return False
|
return False
|
||||||
if request.user.has_perm('judge.edit_all_contest') or obj is None:
|
if obj is None:
|
||||||
return True
|
return True
|
||||||
return obj.organizers.filter(id=request.profile.id).exists()
|
return obj.is_editable_by(request.user)
|
||||||
|
|
||||||
def _rescore(self, contest_key):
|
def _rescore(self, contest_key):
|
||||||
from judge.tasks import rescore_contest
|
from judge.tasks import rescore_contest
|
||||||
|
@ -232,14 +239,10 @@ class ContestAdmin(VersionAdmin):
|
||||||
if not request.user.has_perm('judge.contest_rating'):
|
if not request.user.has_perm('judge.contest_rating'):
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if connection.vendor == 'sqlite':
|
with connection.cursor() as cursor:
|
||||||
Rating.objects.all().delete()
|
|
||||||
else:
|
|
||||||
cursor = connection.cursor()
|
|
||||||
cursor.execute('TRUNCATE TABLE `%s`' % Rating._meta.db_table)
|
cursor.execute('TRUNCATE TABLE `%s`' % Rating._meta.db_table)
|
||||||
cursor.close()
|
|
||||||
Profile.objects.update(rating=None)
|
Profile.objects.update(rating=None)
|
||||||
for contest in Contest.objects.filter(is_rated=True).order_by('end_time'):
|
for contest in Contest.objects.filter(is_rated=True, end_time__lte=timezone.now()).order_by('end_time'):
|
||||||
rate_contest(contest)
|
rate_contest(contest)
|
||||||
return HttpResponseRedirect(reverse('admin:judge_contest_changelist'))
|
return HttpResponseRedirect(reverse('admin:judge_contest_changelist'))
|
||||||
|
|
||||||
|
@ -247,16 +250,21 @@ class ContestAdmin(VersionAdmin):
|
||||||
if not request.user.has_perm('judge.contest_rating'):
|
if not request.user.has_perm('judge.contest_rating'):
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
contest = get_object_or_404(Contest, id=id)
|
contest = get_object_or_404(Contest, id=id)
|
||||||
if not contest.is_rated:
|
if not contest.is_rated or not contest.ended:
|
||||||
raise Http404()
|
raise Http404()
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
contest.rate()
|
contest.rate()
|
||||||
return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('admin:judge_contest_changelist')))
|
return HttpResponseRedirect(request.META.get('HTTP_REFERER', reverse('admin:judge_contest_changelist')))
|
||||||
|
|
||||||
def get_form(self, *args, **kwargs):
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
form = super(ContestAdmin, self).get_form(*args, **kwargs)
|
form = super(ContestAdmin, self).get_form(request, obj, **kwargs)
|
||||||
|
if 'problem_label_script' in form.base_fields:
|
||||||
|
# form.base_fields['problem_label_script'] does not exist when the user has only view permission
|
||||||
|
# on the model.
|
||||||
|
form.base_fields['problem_label_script'].widget = AceWidget('lua', request.profile.ace_theme)
|
||||||
|
|
||||||
perms = ('edit_own_contest', 'edit_all_contest')
|
perms = ('edit_own_contest', 'edit_all_contest')
|
||||||
form.base_fields['organizers'].queryset = Profile.objects.filter(
|
form.base_fields['curators'].queryset = Profile.objects.filter(
|
||||||
Q(user__is_superuser=True) |
|
Q(user__is_superuser=True) |
|
||||||
Q(user__groups__permissions__codename__in=perms) |
|
Q(user__groups__permissions__codename__in=perms) |
|
||||||
Q(user__user_permissions__codename__in=perms),
|
Q(user__user_permissions__codename__in=perms),
|
||||||
|
@ -274,7 +282,7 @@ class ContestParticipationForm(ModelForm):
|
||||||
|
|
||||||
class ContestParticipationAdmin(admin.ModelAdmin):
|
class ContestParticipationAdmin(admin.ModelAdmin):
|
||||||
fields = ('contest', 'user', 'real_start', 'virtual', 'is_disqualified')
|
fields = ('contest', 'user', 'real_start', 'virtual', 'is_disqualified')
|
||||||
list_display = ('contest', 'username', 'show_virtual', 'real_start', 'score', 'cumtime')
|
list_display = ('contest', 'username', 'show_virtual', 'real_start', 'score', 'cumtime', 'tiebreaker')
|
||||||
actions = ['recalculate_results']
|
actions = ['recalculate_results']
|
||||||
actions_on_bottom = actions_on_top = True
|
actions_on_bottom = actions_on_top = True
|
||||||
search_fields = ('contest__key', 'contest__name', 'user__user__username')
|
search_fields = ('contest__key', 'contest__name', 'user__user__username')
|
||||||
|
@ -284,7 +292,7 @@ class ContestParticipationAdmin(admin.ModelAdmin):
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
return super(ContestParticipationAdmin, self).get_queryset(request).only(
|
return super(ContestParticipationAdmin, self).get_queryset(request).only(
|
||||||
'contest__name', 'contest__format_name', 'contest__format_config',
|
'contest__name', 'contest__format_name', 'contest__format_config',
|
||||||
'user__user__username', 'real_start', 'score', 'cumtime', 'virtual',
|
'user__user__username', 'real_start', 'score', 'cumtime', 'tiebreaker', 'virtual',
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
|
|
|
@ -30,7 +30,7 @@ class JudgeAppConfig(AppConfig):
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lang = Language.get_python3()
|
lang = Language.get_default_language()
|
||||||
for user in User.objects.filter(profile=None):
|
for user in User.objects.filter(profile=None):
|
||||||
# These poor profileless users
|
# These poor profileless users
|
||||||
profile = Profile(user=user, language=lang)
|
profile = Profile(user=user, language=lang)
|
||||||
|
|
|
@ -80,8 +80,10 @@ class CommentedDetailView(TemplateResponseMixin, SingleObjectMixin, View):
|
||||||
return self.comment_page
|
return self.comment_page
|
||||||
|
|
||||||
def is_comment_locked(self):
|
def is_comment_locked(self):
|
||||||
return (CommentLock.objects.filter(page=self.get_comment_page()).exists() and
|
if self.request.user.has_perm('judge.override_comment_lock'):
|
||||||
not self.request.user.has_perm('judge.override_comment_lock'))
|
return False
|
||||||
|
return (CommentLock.objects.filter(page=self.get_comment_page()).exists()
|
||||||
|
or (self.request.in_contest and self.request.participation.contest.use_clarifications))
|
||||||
|
|
||||||
@method_decorator(login_required)
|
@method_decorator(login_required)
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
from judge.contest_format.atcoder import AtCoderContestFormat
|
from judge.contest_format.atcoder import AtCoderContestFormat
|
||||||
from judge.contest_format.default import DefaultContestFormat
|
from judge.contest_format.default import DefaultContestFormat
|
||||||
from judge.contest_format.ecoo import ECOOContestFormat
|
from judge.contest_format.ecoo import ECOOContestFormat
|
||||||
|
from judge.contest_format.icpc import ICPCContestFormat
|
||||||
from judge.contest_format.ioi import IOIContestFormat
|
from judge.contest_format.ioi import IOIContestFormat
|
||||||
from judge.contest_format.registry import choices, formats
|
from judge.contest_format.registry import choices, formats
|
||||||
|
|
|
@ -91,6 +91,7 @@ class AtCoderContestFormat(DefaultContestFormat):
|
||||||
|
|
||||||
participation.cumtime = cumtime + penalty
|
participation.cumtime = cumtime + penalty
|
||||||
participation.score = points
|
participation.score = points
|
||||||
|
participation.tiebreaker = 0
|
||||||
participation.format_data = format_data
|
participation.format_data = format_data
|
||||||
participation.save()
|
participation.save()
|
||||||
|
|
||||||
|
|
|
@ -82,6 +82,14 @@ class BaseContestFormat(six.with_metaclass(ABCMeta)):
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_contest_problem_label_script(self):
|
||||||
|
"""
|
||||||
|
Returns the default Lua script to generate contest problem labels.
|
||||||
|
:return: A string, the Lua script.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def best_solution_state(cls, points, total):
|
def best_solution_state(cls, points, total):
|
||||||
if not points:
|
if not points:
|
||||||
|
|
|
@ -41,6 +41,7 @@ class DefaultContestFormat(BaseContestFormat):
|
||||||
|
|
||||||
participation.cumtime = max(cumtime, 0)
|
participation.cumtime = max(cumtime, 0)
|
||||||
participation.score = points
|
participation.score = points
|
||||||
|
participation.tiebreaker = 0
|
||||||
participation.format_data = format_data
|
participation.format_data = format_data
|
||||||
participation.save()
|
participation.save()
|
||||||
|
|
||||||
|
@ -68,3 +69,10 @@ class DefaultContestFormat(BaseContestFormat):
|
||||||
|
|
||||||
def get_problem_breakdown(self, participation, contest_problems):
|
def get_problem_breakdown(self, participation, contest_problems):
|
||||||
return [(participation.format_data or {}).get(str(contest_problem.id)) for contest_problem in contest_problems]
|
return [(participation.format_data or {}).get(str(contest_problem.id)) for contest_problem in contest_problems]
|
||||||
|
|
||||||
|
def get_contest_problem_label_script(self):
|
||||||
|
return '''
|
||||||
|
function(n)
|
||||||
|
return tostring(math.floor(n + 1))
|
||||||
|
end
|
||||||
|
'''
|
|
@ -92,6 +92,7 @@ class ECOOContestFormat(DefaultContestFormat):
|
||||||
|
|
||||||
participation.cumtime = cumtime
|
participation.cumtime = cumtime
|
||||||
participation.score = points
|
participation.score = points
|
||||||
|
participation.tiebreaker = 0
|
||||||
participation.format_data = format_data
|
participation.format_data = format_data
|
||||||
participation.save()
|
participation.save()
|
||||||
|
|
||||||
|
|
129
judge/contest_format/icpc.py
Normal file
129
judge/contest_format/icpc.py
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import connection
|
||||||
|
from django.template.defaultfilters import floatformat
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import gettext_lazy
|
||||||
|
|
||||||
|
from judge.contest_format.default import DefaultContestFormat
|
||||||
|
from judge.contest_format.registry import register_contest_format
|
||||||
|
from judge.timezone import from_database_time
|
||||||
|
from judge.utils.timedelta import nice_repr
|
||||||
|
|
||||||
|
|
||||||
|
@register_contest_format('icpc')
|
||||||
|
class ICPCContestFormat(DefaultContestFormat):
|
||||||
|
name = gettext_lazy('ICPC')
|
||||||
|
config_defaults = {'penalty': 20}
|
||||||
|
config_validators = {'penalty': lambda x: x >= 0}
|
||||||
|
'''
|
||||||
|
penalty: Number of penalty minutes each incorrect submission adds. Defaults to 20.
|
||||||
|
'''
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate(cls, config):
|
||||||
|
if config is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
raise ValidationError('ICPC-styled contest expects no config or dict as config')
|
||||||
|
|
||||||
|
for key, value in config.items():
|
||||||
|
if key not in cls.config_defaults:
|
||||||
|
raise ValidationError('unknown config key "%s"' % key)
|
||||||
|
if not isinstance(value, type(cls.config_defaults[key])):
|
||||||
|
raise ValidationError('invalid type for config key "%s"' % key)
|
||||||
|
if not cls.config_validators[key](value):
|
||||||
|
raise ValidationError('invalid value "%s" for config key "%s"' % (value, key))
|
||||||
|
|
||||||
|
def __init__(self, contest, config):
|
||||||
|
self.config = self.config_defaults.copy()
|
||||||
|
self.config.update(config or {})
|
||||||
|
self.contest = contest
|
||||||
|
|
||||||
|
def update_participation(self, participation):
|
||||||
|
cumtime = 0
|
||||||
|
last = 0
|
||||||
|
penalty = 0
|
||||||
|
score = 0
|
||||||
|
format_data = {}
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT MAX(cs.points) as `points`, (
|
||||||
|
SELECT MIN(csub.date)
|
||||||
|
FROM judge_contestsubmission ccs LEFT OUTER JOIN
|
||||||
|
judge_submission csub ON (csub.id = ccs.submission_id)
|
||||||
|
WHERE ccs.problem_id = cp.id AND ccs.participation_id = %s AND ccs.points = MAX(cs.points)
|
||||||
|
) AS `time`, cp.id AS `prob`
|
||||||
|
FROM judge_contestproblem cp INNER JOIN
|
||||||
|
judge_contestsubmission cs ON (cs.problem_id = cp.id AND cs.participation_id = %s) LEFT OUTER JOIN
|
||||||
|
judge_submission sub ON (sub.id = cs.submission_id)
|
||||||
|
GROUP BY cp.id
|
||||||
|
''', (participation.id, participation.id))
|
||||||
|
|
||||||
|
for points, time, prob in cursor.fetchall():
|
||||||
|
time = from_database_time(time)
|
||||||
|
dt = (time - participation.start).total_seconds()
|
||||||
|
|
||||||
|
# Compute penalty
|
||||||
|
if self.config['penalty']:
|
||||||
|
# An IE can have a submission result of `None`
|
||||||
|
subs = participation.submissions.exclude(submission__result__isnull=True) \
|
||||||
|
.exclude(submission__result__in=['IE', 'CE']) \
|
||||||
|
.filter(problem_id=prob)
|
||||||
|
if points:
|
||||||
|
prev = subs.filter(submission__date__lte=time).count() - 1
|
||||||
|
penalty += prev * self.config['penalty'] * 60
|
||||||
|
else:
|
||||||
|
# We should always display the penalty, even if the user has a score of 0
|
||||||
|
prev = subs.count()
|
||||||
|
else:
|
||||||
|
prev = 0
|
||||||
|
|
||||||
|
if points:
|
||||||
|
cumtime += dt
|
||||||
|
last = max(last, dt)
|
||||||
|
|
||||||
|
format_data[str(prob)] = {'time': dt, 'points': points, 'penalty': prev}
|
||||||
|
score += points
|
||||||
|
|
||||||
|
participation.cumtime = max(0, cumtime + penalty)
|
||||||
|
participation.score = score
|
||||||
|
participation.tiebreaker = last # field is sorted from least to greatest
|
||||||
|
participation.format_data = format_data
|
||||||
|
participation.save()
|
||||||
|
|
||||||
|
def display_user_problem(self, participation, contest_problem):
|
||||||
|
format_data = (participation.format_data or {}).get(str(contest_problem.id))
|
||||||
|
if format_data:
|
||||||
|
penalty = format_html('<small style="color:red"> ({penalty})</small>',
|
||||||
|
penalty=floatformat(format_data['penalty'])) if format_data['penalty'] else ''
|
||||||
|
return format_html(
|
||||||
|
'<td class="{state}"><a href="{url}">{points}{penalty}<div class="solving-time">{time}</div></a></td>',
|
||||||
|
state=(('pretest-' if self.contest.run_pretests_only and contest_problem.is_pretested else '') +
|
||||||
|
self.best_solution_state(format_data['points'], contest_problem.points)),
|
||||||
|
url=reverse('contest_user_submissions',
|
||||||
|
args=[self.contest.key, participation.user.user.username, contest_problem.problem.code]),
|
||||||
|
points=floatformat(format_data['points']),
|
||||||
|
penalty=penalty,
|
||||||
|
time=nice_repr(timedelta(seconds=format_data['time']), 'noday'),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return mark_safe('<td></td>')
|
||||||
|
|
||||||
|
def get_contest_problem_label_script(self):
|
||||||
|
return '''
|
||||||
|
function(n)
|
||||||
|
n = n + 1
|
||||||
|
ret = ""
|
||||||
|
while n > 0 do
|
||||||
|
ret = string.char((n - 1) % 26 + 65) .. ret
|
||||||
|
n = math.floor((n - 1) / 26)
|
||||||
|
end
|
||||||
|
return ret
|
||||||
|
end
|
||||||
|
'''
|
|
@ -73,6 +73,7 @@ class IOIContestFormat(DefaultContestFormat):
|
||||||
|
|
||||||
participation.cumtime = max(cumtime, 0)
|
participation.cumtime = max(cumtime, 0)
|
||||||
participation.score = points
|
participation.score = points
|
||||||
|
participation.tiebreaker = 0
|
||||||
participation.format_data = format_data
|
participation.format_data = format_data
|
||||||
participation.save()
|
participation.save()
|
||||||
|
|
||||||
|
|
|
@ -27,11 +27,12 @@ else:
|
||||||
def wrap(self, source, outfile):
|
def wrap(self, source, outfile):
|
||||||
return self._wrap_div(self._wrap_pre(_wrap_code(source)))
|
return self._wrap_div(self._wrap_pre(_wrap_code(source)))
|
||||||
|
|
||||||
def highlight_code(code, language, cssclass='codehilite'):
|
def highlight_code(code, language, cssclass='codehilite', linenos=True):
|
||||||
try:
|
try:
|
||||||
lexer = pygments.lexers.get_lexer_by_name(language)
|
lexer = pygments.lexers.get_lexer_by_name(language)
|
||||||
except pygments.util.ClassNotFound:
|
except pygments.util.ClassNotFound:
|
||||||
return _make_pre_code(code)
|
return _make_pre_code(code)
|
||||||
|
|
||||||
# return mark_safe(pygments.highlight(code, lexer, HtmlCodeFormatter(cssclass=cssclass, linenos='table')))
|
if linenos:
|
||||||
|
return mark_safe(pygments.highlight(code, lexer, HtmlCodeFormatter(cssclass=cssclass, linenos='table')))
|
||||||
return mark_safe(pygments.highlight(code, lexer, HtmlCodeFormatter(cssclass=cssclass)))
|
return mark_safe(pygments.highlight(code, lexer, HtmlCodeFormatter(cssclass=cssclass)))
|
||||||
|
|
|
@ -8,7 +8,7 @@ from statici18n.templatetags.statici18n import inlinei18n
|
||||||
|
|
||||||
from judge.highlight_code import highlight_code
|
from judge.highlight_code import highlight_code
|
||||||
from judge.user_translations import gettext
|
from judge.user_translations import gettext
|
||||||
from . import (camo, datetime, filesize, gravatar, language, markdown, rating, reference, render, social,
|
from . import (camo, chat, datetime, filesize, gravatar, language, markdown, rating, reference, render, social,
|
||||||
spaceless, submission, timedelta)
|
spaceless, submission, timedelta)
|
||||||
from . import registry
|
from . import registry
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
from judge.utils.camo import client as camo_client
|
from judge.utils.camo import client as camo_client
|
||||||
from . import registry
|
from . import registry
|
||||||
|
|
||||||
|
|
||||||
@registry.filter
|
@registry.filter
|
||||||
def camo(url):
|
def camo(url):
|
||||||
if camo_client is None:
|
if camo_client is None:
|
||||||
|
|
6
judge/jinja2/chat.py
Normal file
6
judge/jinja2/chat.py
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
from . import registry
|
||||||
|
from chat_box.utils import encrypt_url
|
||||||
|
|
||||||
|
@registry.function
|
||||||
|
def chat_param(request_profile, profile):
|
||||||
|
return encrypt_url(request_profile.id, profile.id)
|
|
@ -12,6 +12,7 @@ from lxml.etree import ParserError, XMLSyntaxError
|
||||||
from judge.highlight_code import highlight_code
|
from judge.highlight_code import highlight_code
|
||||||
from judge.jinja2.markdown.lazy_load import lazy_load as lazy_load_processor
|
from judge.jinja2.markdown.lazy_load import lazy_load as lazy_load_processor
|
||||||
from judge.jinja2.markdown.math import MathInlineGrammar, MathInlineLexer, MathRenderer
|
from judge.jinja2.markdown.math import MathInlineGrammar, MathInlineLexer, MathRenderer
|
||||||
|
from judge.jinja2.markdown.spoiler import SpoilerInlineGrammar, SpoilerInlineLexer, SpoilerRenderer
|
||||||
from judge.utils.camo import client as camo_client
|
from judge.utils.camo import client as camo_client
|
||||||
from judge.utils.texoid import TEXOID_ENABLED, TexoidRenderer
|
from judge.utils.texoid import TEXOID_ENABLED, TexoidRenderer
|
||||||
from .. import registry
|
from .. import registry
|
||||||
|
@ -26,15 +27,15 @@ class CodeSafeInlineGrammar(mistune.InlineGrammar):
|
||||||
emphasis = re.compile(r'^\*((?:\*\*|[^\*])+?)()\*(?!\*)') # *word*
|
emphasis = re.compile(r'^\*((?:\*\*|[^\*])+?)()\*(?!\*)') # *word*
|
||||||
|
|
||||||
|
|
||||||
class AwesomeInlineGrammar(MathInlineGrammar, CodeSafeInlineGrammar):
|
class AwesomeInlineGrammar(MathInlineGrammar, SpoilerInlineGrammar, CodeSafeInlineGrammar):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AwesomeInlineLexer(MathInlineLexer, mistune.InlineLexer):
|
class AwesomeInlineLexer(MathInlineLexer, SpoilerInlineLexer, mistune.InlineLexer):
|
||||||
grammar_class = AwesomeInlineGrammar
|
grammar_class = AwesomeInlineGrammar
|
||||||
|
|
||||||
|
|
||||||
class AwesomeRenderer(MathRenderer, mistune.Renderer):
|
class AwesomeRenderer(MathRenderer, SpoilerRenderer, mistune.Renderer):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
self.nofollow = kwargs.pop('nofollow', True)
|
self.nofollow = kwargs.pop('nofollow', True)
|
||||||
self.texoid = TexoidRenderer() if kwargs.pop('texoid', False) else None
|
self.texoid = TexoidRenderer() if kwargs.pop('texoid', False) else None
|
||||||
|
@ -128,7 +129,6 @@ def markdown(value, style, math_engine=None, lazy_load=False):
|
||||||
markdown = mistune.Markdown(renderer=renderer, inline=AwesomeInlineLexer,
|
markdown = mistune.Markdown(renderer=renderer, inline=AwesomeInlineLexer,
|
||||||
parse_block_html=1, parse_inline_html=1)
|
parse_block_html=1, parse_inline_html=1)
|
||||||
result = markdown(value)
|
result = markdown(value)
|
||||||
|
|
||||||
if post_processors:
|
if post_processors:
|
||||||
try:
|
try:
|
||||||
tree = html.fromstring(result, parser=html.HTMLParser(recover=True))
|
tree = html.fromstring(result, parser=html.HTMLParser(recover=True))
|
||||||
|
|
27
judge/jinja2/markdown/spoiler.py
Normal file
27
judge/jinja2/markdown/spoiler.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import re
|
||||||
|
import mistune
|
||||||
|
|
||||||
|
|
||||||
|
class SpoilerInlineGrammar(mistune.InlineGrammar):
|
||||||
|
spoiler = re.compile(r'^\|\|(.+?)\s+([\s\S]+?)\s*\|\|')
|
||||||
|
|
||||||
|
|
||||||
|
class SpoilerInlineLexer(mistune.InlineLexer):
|
||||||
|
grammar_class = SpoilerInlineGrammar
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.default_rules.insert(0, 'spoiler')
|
||||||
|
super(SpoilerInlineLexer, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def output_spoiler(self, m):
|
||||||
|
return self.renderer.spoiler(m.group(1), m.group(2))
|
||||||
|
|
||||||
|
|
||||||
|
class SpoilerRenderer(mistune.Renderer):
|
||||||
|
def spoiler(self, summary, text):
|
||||||
|
return '''<details>
|
||||||
|
<summary style="color: brown">
|
||||||
|
<span class="spoiler-summary">%s</span>
|
||||||
|
</summary>
|
||||||
|
<div class="spoiler-text">%s</div>
|
||||||
|
</details>''' % (summary, text)
|
|
@ -24,5 +24,7 @@ def seconds(timedelta):
|
||||||
|
|
||||||
@registry.filter
|
@registry.filter
|
||||||
@registry.render_with('time-remaining-fragment.html')
|
@registry.render_with('time-remaining-fragment.html')
|
||||||
def as_countdown(timedelta):
|
def as_countdown(time):
|
||||||
return {'countdown': timedelta}
|
time_now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
initial = abs(time - time_now)
|
||||||
|
return {'countdown': time, 'initial': initial}
|
||||||
|
|
|
@ -8,8 +8,8 @@ from django.template.loader import get_template
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
|
|
||||||
from judge.models import Problem, ProblemTranslation
|
from judge.models import Problem, ProblemTranslation
|
||||||
from judge.pdf_problems import DefaultPdfMaker, PhantomJSPdfMaker, PuppeteerPDFRender, SlimerJSPdfMaker
|
from judge.pdf_problems import DefaultPdfMaker, PhantomJSPdfMaker, PuppeteerPDFRender, SeleniumPDFRender, \
|
||||||
|
SlimerJSPdfMaker
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = 'renders a PDF file of a problem'
|
help = 'renders a PDF file of a problem'
|
||||||
|
@ -24,6 +24,7 @@ class Command(BaseCommand):
|
||||||
parser.add_argument('-s', '--slimerjs', action='store_const', const=SlimerJSPdfMaker, dest='engine')
|
parser.add_argument('-s', '--slimerjs', action='store_const', const=SlimerJSPdfMaker, dest='engine')
|
||||||
parser.add_argument('-c', '--chrome', '--puppeteer', action='store_const',
|
parser.add_argument('-c', '--chrome', '--puppeteer', action='store_const',
|
||||||
const=PuppeteerPDFRender, dest='engine')
|
const=PuppeteerPDFRender, dest='engine')
|
||||||
|
parser.add_argument('-S', '--selenium', action='store_const', const=SeleniumPDFRender, dest='engine')
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
try:
|
try:
|
||||||
|
|
|
@ -6,6 +6,11 @@ from django.db import migrations, models
|
||||||
import judge.models.runtime
|
import judge.models.runtime
|
||||||
|
|
||||||
|
|
||||||
|
def create_python3(apps, schema_editor):
|
||||||
|
Language = apps.get_model('judge', 'Language')
|
||||||
|
Language.objects.get_or_create(key='PY3', defaults={'name': 'Python 3'})[0]
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
@ -13,6 +18,7 @@ class Migration(migrations.Migration):
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
migrations.RunPython(create_python3, reverse_code=migrations.RunPython.noop),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='profile',
|
model_name='profile',
|
||||||
name='language',
|
name='language',
|
||||||
|
|
63
judge/migrations/0115_auto_20210525_0222.py
Normal file
63
judge/migrations/0115_auto_20210525_0222.py
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
# Generated by Django 2.2.17 on 2021-05-24 19:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
def hide_scoreboard_eq_true(apps, schema_editor):
|
||||||
|
Contest = apps.get_model('judge', 'Contest')
|
||||||
|
Contest.objects.filter(hide_scoreboard=True).update(scoreboard_visibility='C')
|
||||||
|
|
||||||
|
|
||||||
|
def scoreboard_visibility_eq_contest(apps, schema_editor):
|
||||||
|
Contest = apps.get_model('judge', 'Contest')
|
||||||
|
Contest.objects.filter(scoreboard_visibility__in=('C', 'P')).update(hide_scoreboard=True)
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('judge', '0114_auto_20201228_1041'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='contest',
|
||||||
|
options={'permissions': (('see_private_contest', 'See private contests'), ('edit_own_contest', 'Edit own contests'), ('edit_all_contest', 'Edit all contests'), ('clone_contest', 'Clone contest'), ('moss_contest', 'MOSS contest'), ('contest_rating', 'Rate contests'), ('contest_access_code', 'Contest access codes'), ('create_private_contest', 'Create private contests'), ('change_contest_visibility', 'Change contest visibility'), ('contest_problem_label', 'Edit contest problem label script')), 'verbose_name': 'contest', 'verbose_name_plural': 'contests'},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='contest',
|
||||||
|
name='hide_scoreboard',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='contest',
|
||||||
|
name='organizers',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contest',
|
||||||
|
name='authors',
|
||||||
|
field=models.ManyToManyField(help_text='These users will be able to edit the contest.', related_name='_contest_authors_+', to='judge.Profile'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contest',
|
||||||
|
name='curators',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='These users will be able to edit the contest, but will not be listed as authors.', related_name='_contest_curators_+', to='judge.Profile'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contest',
|
||||||
|
name='problem_label_script',
|
||||||
|
field=models.TextField(blank=True, help_text='A custom Lua function to generate problem labels. Requires a single function with an integer parameter, the zero-indexed contest problem index, and returns a string, the label.', verbose_name='contest problem label script'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contest',
|
||||||
|
name='scoreboard_visibility',
|
||||||
|
field=models.CharField(choices=[('V', 'Visible'), ('C', 'Hidden for duration of contest'), ('P', 'Hidden for duration of participation')], default='V', help_text='Scoreboard visibility through the duration of the contest', max_length=1, verbose_name='scoreboard visibility'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contest',
|
||||||
|
name='testers',
|
||||||
|
field=models.ManyToManyField(blank=True, help_text='These users will be able to view the contest, but not edit it.', related_name='_contest_testers_+', to='judge.Profile'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contestparticipation',
|
||||||
|
name='tiebreaker',
|
||||||
|
field=models.FloatField(default=0.0, verbose_name='tie-breaking field'),
|
||||||
|
),
|
||||||
|
]
|
18
judge/migrations/0116_auto_20211011_0645.py
Normal file
18
judge/migrations/0116_auto_20211011_0645.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 2.2.17 on 2021-10-10 23:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('judge', '0115_auto_20210525_0222'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='contest',
|
||||||
|
name='format_name',
|
||||||
|
field=models.CharField(choices=[('atcoder', 'AtCoder'), ('default', 'Default'), ('ecoo', 'ECOO'), ('icpc', 'ICPC'), ('ioi', 'IOI')], default='default', help_text='The contest format module to use.', max_length=32, verbose_name='contest format'),
|
||||||
|
),
|
||||||
|
]
|
18
judge/migrations/0117_auto_20211209_0612.py
Normal file
18
judge/migrations/0117_auto_20211209_0612.py
Normal file
File diff suppressed because one or more lines are too long
208
judge/migrations/0118_rating.py
Normal file
208
judge/migrations/0118_rating.py
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
import math
|
||||||
|
from operator import attrgetter, itemgetter
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.models import Count, OuterRef, Subquery
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
def tie_ranker(iterable, key=attrgetter('points')):
|
||||||
|
rank = 0
|
||||||
|
delta = 1
|
||||||
|
last = None
|
||||||
|
buf = []
|
||||||
|
for item in iterable:
|
||||||
|
new = key(item)
|
||||||
|
if new != last:
|
||||||
|
for _ in buf:
|
||||||
|
yield rank + (delta - 1) / 2.0
|
||||||
|
rank += delta
|
||||||
|
delta = 0
|
||||||
|
buf = []
|
||||||
|
delta += 1
|
||||||
|
buf.append(item)
|
||||||
|
last = key(item)
|
||||||
|
for _ in buf:
|
||||||
|
yield rank + (delta - 1) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def rational_approximation(t):
|
||||||
|
# Abramowitz and Stegun formula 26.2.23.
|
||||||
|
# The absolute value of the error should be less than 4.5 e-4.
|
||||||
|
c = [2.515517, 0.802853, 0.010328]
|
||||||
|
d = [1.432788, 0.189269, 0.001308]
|
||||||
|
numerator = (c[2] * t + c[1]) * t + c[0]
|
||||||
|
denominator = ((d[2] * t + d[1]) * t + d[0]) * t + 1.0
|
||||||
|
return t - numerator / denominator
|
||||||
|
|
||||||
|
|
||||||
|
def normal_CDF_inverse(p):
|
||||||
|
assert 0.0 < p < 1
|
||||||
|
|
||||||
|
# See article above for explanation of this section.
|
||||||
|
if p < 0.5:
|
||||||
|
# F^-1(p) = - G^-1(p)
|
||||||
|
return -rational_approximation(math.sqrt(-2.0 * math.log(p)))
|
||||||
|
else:
|
||||||
|
# F^-1(p) = G^-1(1-p)
|
||||||
|
return rational_approximation(math.sqrt(-2.0 * math.log(1.0 - p)))
|
||||||
|
|
||||||
|
|
||||||
|
def WP(RA, RB, VA, VB):
|
||||||
|
return (math.erf((RB - RA) / math.sqrt(2 * (VA * VA + VB * VB))) + 1) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def recalculate_ratings(old_rating, old_volatility, actual_rank, times_rated, is_disqualified):
|
||||||
|
# actual_rank: 1 is first place, N is last place
|
||||||
|
# if there are ties, use the average of places (if places 2, 3, 4, 5 tie, use 3.5 for all of them)
|
||||||
|
|
||||||
|
N = len(old_rating)
|
||||||
|
new_rating = old_rating[:]
|
||||||
|
new_volatility = old_volatility[:]
|
||||||
|
if N <= 1:
|
||||||
|
return new_rating, new_volatility
|
||||||
|
|
||||||
|
ranking = list(range(N))
|
||||||
|
ranking.sort(key=old_rating.__getitem__, reverse=True)
|
||||||
|
|
||||||
|
ave_rating = float(sum(old_rating)) / N
|
||||||
|
sum1 = sum(i * i for i in old_volatility) / N
|
||||||
|
sum2 = sum((i - ave_rating) ** 2 for i in old_rating) / (N - 1)
|
||||||
|
CF = math.sqrt(sum1 + sum2)
|
||||||
|
|
||||||
|
for i in range(N):
|
||||||
|
ERank = 0.5
|
||||||
|
for j in range(N):
|
||||||
|
ERank += WP(old_rating[i], old_rating[j], old_volatility[i], old_volatility[j])
|
||||||
|
|
||||||
|
EPerf = -normal_CDF_inverse((ERank - 0.5) / N)
|
||||||
|
APerf = -normal_CDF_inverse((actual_rank[i] - 0.5) / N)
|
||||||
|
PerfAs = old_rating[i] + CF * (APerf - EPerf)
|
||||||
|
Weight = 1.0 / (1 - (0.42 / (times_rated[i] + 1) + 0.18)) - 1.0
|
||||||
|
if old_rating[i] > 2500:
|
||||||
|
Weight *= 0.8
|
||||||
|
elif old_rating[i] >= 2000:
|
||||||
|
Weight *= 0.9
|
||||||
|
|
||||||
|
Cap = 150.0 + 1500.0 / (times_rated[i] + 2)
|
||||||
|
|
||||||
|
new_rating[i] = (old_rating[i] + Weight * PerfAs) / (1.0 + Weight)
|
||||||
|
|
||||||
|
if abs(old_rating[i] - new_rating[i]) > Cap:
|
||||||
|
if old_rating[i] < new_rating[i]:
|
||||||
|
new_rating[i] = old_rating[i] + Cap
|
||||||
|
else:
|
||||||
|
new_rating[i] = old_rating[i] - Cap
|
||||||
|
|
||||||
|
if times_rated[i] == 0:
|
||||||
|
new_volatility[i] = 385
|
||||||
|
else:
|
||||||
|
new_volatility[i] = math.sqrt(((new_rating[i] - old_rating[i]) ** 2) / Weight +
|
||||||
|
(old_volatility[i] ** 2) / (Weight + 1))
|
||||||
|
|
||||||
|
if is_disqualified[i]:
|
||||||
|
# DQed users can manipulate TopCoder ratings to get higher volatility in order to increase their rating
|
||||||
|
# later on, prohibit this by ensuring their volatility never increases in this situation
|
||||||
|
new_volatility[i] = min(new_volatility[i], old_volatility[i])
|
||||||
|
|
||||||
|
# try to keep the sum of ratings constant
|
||||||
|
adjust = float(sum(old_rating) - sum(new_rating)) / N
|
||||||
|
new_rating = list(map(adjust.__add__, new_rating))
|
||||||
|
# inflate a little if we have to so people who placed first don't lose rating
|
||||||
|
best_rank = min(actual_rank)
|
||||||
|
for i in range(N):
|
||||||
|
if abs(actual_rank[i] - best_rank) <= 1e-3 and new_rating[i] < old_rating[i] + 1:
|
||||||
|
new_rating[i] = old_rating[i] + 1
|
||||||
|
return list(map(int, map(round, new_rating))), list(map(int, map(round, new_volatility)))
|
||||||
|
|
||||||
|
|
||||||
|
def tc_rate_contest(contest, Rating, Profile):
|
||||||
|
rating_subquery = Rating.objects.filter(user=OuterRef('user'))
|
||||||
|
rating_sorted = rating_subquery.order_by('-contest__end_time')
|
||||||
|
users = contest.users.order_by('is_disqualified', '-score', 'cumtime', 'tiebreaker') \
|
||||||
|
.annotate(submissions=Count('submission'),
|
||||||
|
last_rating=Coalesce(Subquery(rating_sorted.values('rating')[:1]), 1200),
|
||||||
|
volatility=Coalesce(Subquery(rating_sorted.values('volatility')[:1]), 535),
|
||||||
|
times=Coalesce(Subquery(rating_subquery.order_by().values('user_id')
|
||||||
|
.annotate(count=Count('id')).values('count')), 0)) \
|
||||||
|
.exclude(user_id__in=contest.rate_exclude.all()) \
|
||||||
|
.filter(virtual=0).values('id', 'user_id', 'score', 'cumtime', 'tiebreaker', 'is_disqualified',
|
||||||
|
'last_rating', 'volatility', 'times')
|
||||||
|
if not contest.rate_all:
|
||||||
|
users = users.filter(submissions__gt=0)
|
||||||
|
if contest.rating_floor is not None:
|
||||||
|
users = users.exclude(last_rating__lt=contest.rating_floor)
|
||||||
|
if contest.rating_ceiling is not None:
|
||||||
|
users = users.exclude(last_rating__gt=contest.rating_ceiling)
|
||||||
|
|
||||||
|
users = list(users)
|
||||||
|
participation_ids = list(map(itemgetter('id'), users))
|
||||||
|
user_ids = list(map(itemgetter('user_id'), users))
|
||||||
|
is_disqualified = list(map(itemgetter('is_disqualified'), users))
|
||||||
|
ranking = list(tie_ranker(users, key=itemgetter('score', 'cumtime', 'tiebreaker')))
|
||||||
|
old_rating = list(map(itemgetter('last_rating'), users))
|
||||||
|
old_volatility = list(map(itemgetter('volatility'), users))
|
||||||
|
times_ranked = list(map(itemgetter('times'), users))
|
||||||
|
rating, volatility = recalculate_ratings(old_rating, old_volatility, ranking, times_ranked, is_disqualified)
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
ratings = [Rating(user_id=i, contest=contest, rating=r, volatility=v, last_rated=now, participation_id=p, rank=z)
|
||||||
|
for i, p, r, v, z in zip(user_ids, participation_ids, rating, volatility, ranking)]
|
||||||
|
|
||||||
|
Rating.objects.bulk_create(ratings)
|
||||||
|
|
||||||
|
Profile.objects.filter(contest_history__contest=contest, contest_history__virtual=0).update(
|
||||||
|
rating=Subquery(Rating.objects.filter(user=OuterRef('id'))
|
||||||
|
.order_by('-contest__end_time').values('rating')[:1]))
|
||||||
|
|
||||||
|
|
||||||
|
# inspired by rate_all_view
|
||||||
|
def rate_tc(apps, schema_editor):
|
||||||
|
Contest = apps.get_model('judge', 'Contest')
|
||||||
|
Rating = apps.get_model('judge', 'Rating')
|
||||||
|
Profile = apps.get_model('judge', 'Profile')
|
||||||
|
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute('TRUNCATE TABLE `%s`' % Rating._meta.db_table)
|
||||||
|
Profile.objects.update(rating=None)
|
||||||
|
for contest in Contest.objects.filter(is_rated=True, end_time__lte=timezone.now()).order_by('end_time'):
|
||||||
|
tc_rate_contest(contest, Rating, Profile)
|
||||||
|
|
||||||
|
|
||||||
|
# inspired by rate_all_view
|
||||||
|
def rate_elo_mmr(apps, schema_editor):
|
||||||
|
Rating = apps.get_model('judge', 'Rating')
|
||||||
|
Profile = apps.get_model('judge', 'Profile')
|
||||||
|
|
||||||
|
with schema_editor.connection.cursor() as cursor:
|
||||||
|
cursor.execute('TRUNCATE TABLE `%s`' % Rating._meta.db_table)
|
||||||
|
Profile.objects.update(rating=None)
|
||||||
|
# Don't populate Rating
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('judge', '0117_auto_20211209_0612'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(migrations.RunPython.noop, rate_tc, atomic=True),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='rating',
|
||||||
|
name='mean',
|
||||||
|
field=models.FloatField(verbose_name='raw rating'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='rating',
|
||||||
|
name='performance',
|
||||||
|
field=models.FloatField(verbose_name='contest performance'),
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='rating',
|
||||||
|
name='volatility',
|
||||||
|
field=models.IntegerField(verbose_name='volatility'),
|
||||||
|
),
|
||||||
|
migrations.RunPython(rate_elo_mmr, migrations.RunPython.noop, atomic=True),
|
||||||
|
]
|
|
@ -105,7 +105,7 @@ class Comment(MPTTModel):
|
||||||
try:
|
try:
|
||||||
link = None
|
link = None
|
||||||
if self.page.startswith('p:'):
|
if self.page.startswith('p:'):
|
||||||
link = reverse('problem_detail', args=(self.page[2:],))
|
link = reverse('problem_comments', args=(self.page[2:],))
|
||||||
elif self.page.startswith('c:'):
|
elif self.page.startswith('c:'):
|
||||||
link = reverse('contest_view', args=(self.page[2:],))
|
link = reverse('contest_view', args=(self.page[2:],))
|
||||||
elif self.page.startswith('b:'):
|
elif self.page.startswith('b:'):
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.models import CASCADE
|
from django.db.models import CASCADE, Q
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.translation import gettext, gettext_lazy as _
|
from django.utils.translation import gettext, gettext_lazy as _
|
||||||
from jsonfield import JSONField
|
from jsonfield import JSONField
|
||||||
|
from lupa import LuaRuntime
|
||||||
from moss import MOSS_LANG_C, MOSS_LANG_CC, MOSS_LANG_JAVA, MOSS_LANG_PYTHON, MOSS_LANG_PASCAL
|
from moss import MOSS_LANG_C, MOSS_LANG_CC, MOSS_LANG_JAVA, MOSS_LANG_PYTHON, MOSS_LANG_PASCAL
|
||||||
|
|
||||||
from judge import contest_format
|
from judge import contest_format
|
||||||
|
@ -48,11 +49,25 @@ class ContestTag(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class Contest(models.Model):
|
class Contest(models.Model):
|
||||||
|
SCOREBOARD_VISIBLE = 'V'
|
||||||
|
SCOREBOARD_AFTER_CONTEST = 'C'
|
||||||
|
SCOREBOARD_AFTER_PARTICIPATION = 'P'
|
||||||
|
SCOREBOARD_VISIBILITY = (
|
||||||
|
(SCOREBOARD_VISIBLE, _('Visible')),
|
||||||
|
(SCOREBOARD_AFTER_CONTEST, _('Hidden for duration of contest')),
|
||||||
|
(SCOREBOARD_AFTER_PARTICIPATION, _('Hidden for duration of participation')),
|
||||||
|
)
|
||||||
key = models.CharField(max_length=20, verbose_name=_('contest id'), unique=True,
|
key = models.CharField(max_length=20, verbose_name=_('contest id'), unique=True,
|
||||||
validators=[RegexValidator('^[a-z0-9]+$', _('Contest id must be ^[a-z0-9]+$'))])
|
validators=[RegexValidator('^[a-z0-9]+$', _('Contest id must be ^[a-z0-9]+$'))])
|
||||||
name = models.CharField(max_length=100, verbose_name=_('contest name'), db_index=True)
|
name = models.CharField(max_length=100, verbose_name=_('contest name'), db_index=True)
|
||||||
organizers = models.ManyToManyField(Profile, help_text=_('These people will be able to edit the contest.'),
|
authors = models.ManyToManyField(Profile, help_text=_('These users will be able to edit the contest.'),
|
||||||
related_name='organizers+')
|
related_name='authors+')
|
||||||
|
curators = models.ManyToManyField(Profile, help_text=_('These users will be able to edit the contest, '
|
||||||
|
'but will not be listed as authors.'),
|
||||||
|
related_name='curators+', blank=True)
|
||||||
|
testers = models.ManyToManyField(Profile, help_text=_('These users will be able to view the contest, '
|
||||||
|
'but not edit it.'),
|
||||||
|
blank=True, related_name='testers+')
|
||||||
description = models.TextField(verbose_name=_('description'), blank=True)
|
description = models.TextField(verbose_name=_('description'), blank=True)
|
||||||
problems = models.ManyToManyField(Problem, verbose_name=_('problems'), through='ContestProblem')
|
problems = models.ManyToManyField(Problem, verbose_name=_('problems'), through='ContestProblem')
|
||||||
start_time = models.DateTimeField(verbose_name=_('start time'), db_index=True)
|
start_time = models.DateTimeField(verbose_name=_('start time'), db_index=True)
|
||||||
|
@ -64,10 +79,9 @@ class Contest(models.Model):
|
||||||
'specified organizations.'))
|
'specified organizations.'))
|
||||||
is_rated = models.BooleanField(verbose_name=_('contest rated'), help_text=_('Whether this contest can be rated.'),
|
is_rated = models.BooleanField(verbose_name=_('contest rated'), help_text=_('Whether this contest can be rated.'),
|
||||||
default=False)
|
default=False)
|
||||||
hide_scoreboard = models.BooleanField(verbose_name=_('hide scoreboard'),
|
scoreboard_visibility = models.CharField(verbose_name=_('scoreboard visibility'), default=SCOREBOARD_VISIBLE,
|
||||||
help_text=_('Whether the scoreboard should remain hidden for the duration '
|
max_length=1, help_text=_('Scoreboard visibility through the duration '
|
||||||
'of the contest.'),
|
'of the contest'), choices=SCOREBOARD_VISIBILITY)
|
||||||
default=False)
|
|
||||||
view_contest_scoreboard = models.ManyToManyField(Profile, verbose_name=_('view contest scoreboard'), blank=True,
|
view_contest_scoreboard = models.ManyToManyField(Profile, verbose_name=_('view contest scoreboard'), blank=True,
|
||||||
related_name='view_contest_scoreboard',
|
related_name='view_contest_scoreboard',
|
||||||
help_text=_('These users will be able to view the scoreboard.'))
|
help_text=_('These users will be able to view the scoreboard.'))
|
||||||
|
@ -116,6 +130,10 @@ class Contest(models.Model):
|
||||||
help_text=_('A JSON object to serve as the configuration for the chosen contest format '
|
help_text=_('A JSON object to serve as the configuration for the chosen contest format '
|
||||||
'module. Leave empty to use None. Exact format depends on the contest format '
|
'module. Leave empty to use None. Exact format depends on the contest format '
|
||||||
'selected.'))
|
'selected.'))
|
||||||
|
problem_label_script = models.TextField(verbose_name='contest problem label script', blank=True,
|
||||||
|
help_text='A custom Lua function to generate problem labels. Requires a '
|
||||||
|
'single function with an integer parameter, the zero-indexed '
|
||||||
|
'contest problem index, and returns a string, the label.')
|
||||||
points_precision = models.IntegerField(verbose_name=_('precision points'), default=2,
|
points_precision = models.IntegerField(verbose_name=_('precision points'), default=2,
|
||||||
validators=[MinValueValidator(0), MaxValueValidator(10)],
|
validators=[MinValueValidator(0), MaxValueValidator(10)],
|
||||||
help_text=_('Number of digits to round points to.'))
|
help_text=_('Number of digits to round points to.'))
|
||||||
|
@ -128,30 +146,72 @@ class Contest(models.Model):
|
||||||
def format(self):
|
def format(self):
|
||||||
return self.format_class(self, self.format_config)
|
return self.format_class(self, self.format_config)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def get_label_for_problem(self):
|
||||||
|
def DENY_ALL(obj, attr_name, is_setting):
|
||||||
|
raise AttributeError()
|
||||||
|
lua = LuaRuntime(attribute_filter=DENY_ALL, register_eval=False, register_builtins=False)
|
||||||
|
return lua.eval(self.problem_label_script or self.format.get_contest_problem_label_script())
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
# Django will complain if you didn't fill in start_time or end_time, so we don't have to.
|
# Django will complain if you didn't fill in start_time or end_time, so we don't have to.
|
||||||
if self.start_time and self.end_time and self.start_time >= self.end_time:
|
if self.start_time and self.end_time and self.start_time >= self.end_time:
|
||||||
raise ValidationError('What is this? A contest that ended before it starts?')
|
raise ValidationError('What is this? A contest that ended before it starts?')
|
||||||
self.format_class.validate(self.format_config)
|
self.format_class.validate(self.format_config)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# a contest should have at least one problem, with contest problem index 0
|
||||||
|
# so test it to see if the script returns a valid label.
|
||||||
|
label = self.get_label_for_problem(0)
|
||||||
|
except Exception as e:
|
||||||
|
raise ValidationError('Contest problem label script: %s' % e)
|
||||||
|
else:
|
||||||
|
if not isinstance(label, str):
|
||||||
|
raise ValidationError('Contest problem label script: script should return a string.')
|
||||||
|
|
||||||
def is_in_contest(self, user):
|
def is_in_contest(self, user):
|
||||||
if user.is_authenticated:
|
if user.is_authenticated:
|
||||||
profile = user.profile
|
profile = user.profile
|
||||||
return profile and profile.current_contest is not None and profile.current_contest.contest == self
|
return profile and profile.current_contest is not None and profile.current_contest.contest == self
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def can_see_scoreboard(self, user):
|
def can_see_own_scoreboard(self, user):
|
||||||
if user.has_perm('judge.see_private_contest'):
|
if self.can_see_full_scoreboard(user):
|
||||||
return True
|
return True
|
||||||
if user.is_authenticated and self.organizers.filter(id=user.profile.id).exists():
|
if not self.can_join:
|
||||||
return True
|
|
||||||
if user.is_authenticated and self.view_contest_scoreboard.filter(id=user.profile.id).exists():
|
|
||||||
return True
|
|
||||||
if not self.is_visible:
|
|
||||||
return False
|
return False
|
||||||
if self.start_time is not None and self.start_time > timezone.now():
|
if not self.show_scoreboard and not self.is_in_contest(user):
|
||||||
return False
|
return False
|
||||||
if self.hide_scoreboard and not self.is_in_contest(user) and self.end_time > timezone.now():
|
return True
|
||||||
|
|
||||||
|
def can_see_full_scoreboard(self, user):
|
||||||
|
if self.show_scoreboard:
|
||||||
|
return True
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if user.has_perm('judge.see_private_contest') or user.has_perm('judge.edit_all_contest'):
|
||||||
|
return True
|
||||||
|
if user.profile.id in self.editor_ids:
|
||||||
|
return True
|
||||||
|
if self.view_contest_scoreboard.filter(id=user.profile.id).exists():
|
||||||
|
return True
|
||||||
|
if self.scoreboard_visibility == self.SCOREBOARD_AFTER_PARTICIPATION and self.has_completed_contest(user):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_completed_contest(self, user):
|
||||||
|
if user.is_authenticated:
|
||||||
|
participation = self.users.filter(virtual=ContestParticipation.LIVE, user=user.profile).first()
|
||||||
|
if participation and participation.ended:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def show_scoreboard(self):
|
||||||
|
if not self.can_join:
|
||||||
|
return False
|
||||||
|
if (self.scoreboard_visibility in (self.SCOREBOARD_AFTER_CONTEST, self.SCOREBOARD_AFTER_PARTICIPATION) and
|
||||||
|
not self.ended):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -186,6 +246,19 @@ class Contest(models.Model):
|
||||||
def ended(self):
|
def ended(self):
|
||||||
return self.end_time < self._now
|
return self.end_time < self._now
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def author_ids(self):
|
||||||
|
return Contest.authors.through.objects.filter(contest=self).values_list('profile_id', flat=True)
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def editor_ids(self):
|
||||||
|
return self.author_ids.union(
|
||||||
|
Contest.curators.through.objects.filter(contest=self).values_list('profile_id', flat=True))
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def tester_ids(self):
|
||||||
|
return Contest.testers.through.objects.filter(contest=self).values_list('profile_id', flat=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
@ -198,50 +271,111 @@ class Contest(models.Model):
|
||||||
|
|
||||||
update_user_count.alters_data = True
|
update_user_count.alters_data = True
|
||||||
|
|
||||||
@cached_property
|
class Inaccessible(Exception):
|
||||||
def show_scoreboard(self):
|
pass
|
||||||
if self.hide_scoreboard and not self.ended:
|
|
||||||
return False
|
class PrivateContest(Exception):
|
||||||
return True
|
pass
|
||||||
|
|
||||||
|
def access_check(self, user):
|
||||||
|
# Do unauthenticated check here so we can skip authentication checks later on.
|
||||||
|
if not user.is_authenticated:
|
||||||
|
# Unauthenticated users can only see visible, non-private contests
|
||||||
|
if not self.is_visible:
|
||||||
|
raise self.Inaccessible()
|
||||||
|
if self.is_private or self.is_organization_private:
|
||||||
|
raise self.PrivateContest()
|
||||||
|
return
|
||||||
|
|
||||||
|
# If the user can view or edit all contests
|
||||||
|
if user.has_perm('judge.see_private_contest') or user.has_perm('judge.edit_all_contest'):
|
||||||
|
return
|
||||||
|
|
||||||
|
# User is organizer or curator for contest
|
||||||
|
if user.profile.id in self.editor_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
# User is tester for contest
|
||||||
|
if user.profile.id in self.tester_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Contest is not publicly visible
|
||||||
|
if not self.is_visible:
|
||||||
|
raise self.Inaccessible()
|
||||||
|
|
||||||
|
# Contest is not private
|
||||||
|
if not self.is_private and not self.is_organization_private:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.view_contest_scoreboard.filter(id=user.profile.id).exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
in_org = self.organizations.filter(id__in=user.profile.organizations.all()).exists()
|
||||||
|
in_users = self.private_contestants.filter(id=user.profile.id).exists()
|
||||||
|
|
||||||
|
if not self.is_private and self.is_organization_private:
|
||||||
|
if in_org:
|
||||||
|
return
|
||||||
|
raise self.PrivateContest()
|
||||||
|
|
||||||
|
if self.is_private and not self.is_organization_private:
|
||||||
|
if in_users:
|
||||||
|
return
|
||||||
|
raise self.PrivateContest()
|
||||||
|
|
||||||
|
if self.is_private and self.is_organization_private:
|
||||||
|
if in_org and in_users:
|
||||||
|
return
|
||||||
|
raise self.PrivateContest()
|
||||||
|
|
||||||
def is_accessible_by(self, user):
|
def is_accessible_by(self, user):
|
||||||
# Contest is publicly visible
|
try:
|
||||||
if self.is_visible:
|
self.access_check(user)
|
||||||
# Contest is not private
|
except (self.Inaccessible, self.PrivateContest):
|
||||||
if not self.is_private and not self.is_organization_private:
|
return False
|
||||||
return True
|
else:
|
||||||
if user.is_authenticated:
|
|
||||||
# User is in the organizations it is private to
|
|
||||||
if self.organizations.filter(id__in=user.profile.organizations.all()).exists():
|
|
||||||
return True
|
|
||||||
# User is in the group of private contestants
|
|
||||||
if self.private_contestants.filter(id=user.profile.id).exists():
|
|
||||||
return True
|
|
||||||
if self.view_contest_scoreboard.filter(id=user.profile.id).exists():
|
|
||||||
return True
|
|
||||||
|
|
||||||
# If the user can view all contests
|
|
||||||
if user.has_perm('judge.see_private_contest'):
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# User can edit the contest
|
|
||||||
return self.is_editable_by(user)
|
|
||||||
|
|
||||||
def is_editable_by(self, user):
|
def is_editable_by(self, user):
|
||||||
# If the user can edit all contests
|
# If the user can edit all contests
|
||||||
if user.has_perm('judge.edit_all_contest'):
|
if user.has_perm('judge.edit_all_contest'):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# If the user is a contest organizer
|
# If the user is a contest organizer or curator
|
||||||
if user.has_perm('judge.edit_own_contest') and \
|
if user.has_perm('judge.edit_own_contest') and user.profile.id in self.editor_ids:
|
||||||
self.organizers.filter(id=user.profile.id).exists():
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_visible_contests(cls, user):
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return cls.objects.filter(is_visible=True, is_organization_private=False, is_private=False) \
|
||||||
|
.defer('description').distinct()
|
||||||
|
|
||||||
|
queryset = cls.objects.defer('description')
|
||||||
|
if not (user.has_perm('judge.see_private_contest') or user.has_perm('judge.edit_all_contest')):
|
||||||
|
q = Q(is_visible=True)
|
||||||
|
q &= (
|
||||||
|
Q(view_contest_scoreboard=user.profile) |
|
||||||
|
Q(is_organization_private=False, is_private=False) |
|
||||||
|
Q(is_organization_private=False, is_private=True, private_contestants=user.profile) |
|
||||||
|
Q(is_organization_private=True, is_private=False, organizations__in=user.profile.organizations.all()) |
|
||||||
|
Q(is_organization_private=True, is_private=True, organizations__in=user.profile.organizations.all(),
|
||||||
|
private_contestants=user.profile)
|
||||||
|
)
|
||||||
|
|
||||||
|
q |= Q(authors=user.profile)
|
||||||
|
q |= Q(curators=user.profile)
|
||||||
|
q |= Q(testers=user.profile)
|
||||||
|
queryset = queryset.filter(q)
|
||||||
|
return queryset.distinct()
|
||||||
|
|
||||||
def rate(self):
|
def rate(self):
|
||||||
Rating.objects.filter(contest__end_time__gte=self.end_time).delete()
|
Rating.objects.filter(contest__end_time__range=(self.end_time, self._now)).delete()
|
||||||
for contest in Contest.objects.filter(is_rated=True, end_time__gte=self.end_time).order_by('end_time'):
|
for contest in Contest.objects.filter(
|
||||||
|
is_rated=True, end_time__range=(self.end_time, self._now),
|
||||||
|
).order_by('end_time'):
|
||||||
rate_contest(contest)
|
rate_contest(contest)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -255,6 +389,7 @@ class Contest(models.Model):
|
||||||
('contest_access_code', _('Contest access codes')),
|
('contest_access_code', _('Contest access codes')),
|
||||||
('create_private_contest', _('Create private contests')),
|
('create_private_contest', _('Create private contests')),
|
||||||
('change_contest_visibility', _('Change contest visibility')),
|
('change_contest_visibility', _('Change contest visibility')),
|
||||||
|
('contest_problem_label', _('Edit contest problem label script')),
|
||||||
)
|
)
|
||||||
verbose_name = _('contest')
|
verbose_name = _('contest')
|
||||||
verbose_name_plural = _('contests')
|
verbose_name_plural = _('contests')
|
||||||
|
@ -271,6 +406,7 @@ class ContestParticipation(models.Model):
|
||||||
cumtime = models.PositiveIntegerField(verbose_name=_('cumulative time'), default=0)
|
cumtime = models.PositiveIntegerField(verbose_name=_('cumulative time'), default=0)
|
||||||
is_disqualified = models.BooleanField(verbose_name=_('is disqualified'), default=False,
|
is_disqualified = models.BooleanField(verbose_name=_('is disqualified'), default=False,
|
||||||
help_text=_('Whether this participation is disqualified.'))
|
help_text=_('Whether this participation is disqualified.'))
|
||||||
|
tiebreaker = models.FloatField(verbose_name=_('tie-breaking field'), default=0.0)
|
||||||
virtual = models.IntegerField(verbose_name=_('virtual participation id'), default=LIVE,
|
virtual = models.IntegerField(verbose_name=_('virtual participation id'), default=LIVE,
|
||||||
help_text=_('0 means non-virtual, otherwise the n-th virtual participation.'))
|
help_text=_('0 means non-virtual, otherwise the n-th virtual participation.'))
|
||||||
format_data = JSONField(verbose_name=_('contest format specific data'), null=True, blank=True)
|
format_data = JSONField(verbose_name=_('contest format specific data'), null=True, blank=True)
|
||||||
|
@ -395,7 +531,8 @@ class Rating(models.Model):
|
||||||
related_name='rating', on_delete=CASCADE)
|
related_name='rating', on_delete=CASCADE)
|
||||||
rank = models.IntegerField(verbose_name=_('rank'))
|
rank = models.IntegerField(verbose_name=_('rank'))
|
||||||
rating = models.IntegerField(verbose_name=_('rating'))
|
rating = models.IntegerField(verbose_name=_('rating'))
|
||||||
volatility = models.IntegerField(verbose_name=_('volatility'))
|
mean = models.FloatField(verbose_name=_('raw rating'))
|
||||||
|
performance = models.FloatField(verbose_name=_('contest performance'))
|
||||||
last_rated = models.DateTimeField(db_index=True, verbose_name=_('last rated'))
|
last_rated = models.DateTimeField(db_index=True, verbose_name=_('last rated'))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -5,7 +5,7 @@ from django.contrib.contenttypes.fields import GenericRelation
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import CASCADE, F, QuerySet, SET_NULL
|
from django.db.models import CASCADE, F, Q, QuerySet, SET_NULL
|
||||||
from django.db.models.expressions import RawSQL
|
from django.db.models.expressions import RawSQL
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
@ -220,6 +220,43 @@ class Problem(models.Model):
|
||||||
def is_subs_manageable_by(self, user):
|
def is_subs_manageable_by(self, user):
|
||||||
return user.is_staff and user.has_perm('judge.rejudge_submission') and self.is_editable_by(user)
|
return user.is_staff and user.has_perm('judge.rejudge_submission') and self.is_editable_by(user)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_visible_problems(cls, user):
|
||||||
|
# Do unauthenticated check here so we can skip authentication checks later on.
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return cls.get_public_problems()
|
||||||
|
|
||||||
|
# Conditions for visible problem:
|
||||||
|
# - `judge.edit_all_problem` or `judge.see_private_problem`
|
||||||
|
# - otherwise
|
||||||
|
# - not is_public problems
|
||||||
|
# - author or curator or tester
|
||||||
|
# - is_public problems
|
||||||
|
# - not is_organization_private or in organization or `judge.see_organization_problem`
|
||||||
|
# - author or curator or tester
|
||||||
|
queryset = cls.objects.defer('description')
|
||||||
|
|
||||||
|
if not (user.has_perm('judge.see_private_problem') or user.has_perm('judge.edit_all_problem')):
|
||||||
|
q = Q(is_public=True)
|
||||||
|
if not user.has_perm('judge.see_organization_problem'):
|
||||||
|
# Either not organization private or in the organization.
|
||||||
|
q &= (
|
||||||
|
Q(is_organization_private=False) |
|
||||||
|
Q(is_organization_private=True, organizations__in=user.profile.organizations.all())
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authors, curators, and testers should always have access, so OR at the very end.
|
||||||
|
q |= Q(authors=user.profile)
|
||||||
|
q |= Q(curators=user.profile)
|
||||||
|
q |= Q(testers=user.profile)
|
||||||
|
queryset = queryset.filter(q)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_public_problems(cls):
|
||||||
|
return cls.objects.filter(is_public=True, is_organization_private=False).defer('description')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import errno
|
import errno
|
||||||
import os
|
import os
|
||||||
|
from zipfile import BadZipFile, ZipFile
|
||||||
|
|
||||||
from django.core.validators import FileExtensionValidator
|
from django.core.validators import FileExtensionValidator
|
||||||
|
from django.core.cache import cache
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from judge.utils.problem_data import ProblemDataStorage
|
from judge.utils.problem_data import ProblemDataStorage, get_file_cachekey
|
||||||
|
|
||||||
__all__ = ['problem_data_storage', 'problem_directory_file', 'ProblemData', 'ProblemTestCase', 'CHECKERS']
|
__all__ = ['problem_data_storage', 'problem_directory_file', 'ProblemData', 'ProblemTestCase', 'CHECKERS']
|
||||||
|
|
||||||
|
@ -66,7 +68,16 @@ class ProblemData(models.Model):
|
||||||
self.__original_zipfile = self.zipfile
|
self.__original_zipfile = self.zipfile
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.zipfile != self.__original_zipfile:
|
if self.zipfile != self.__original_zipfile and self.__original_zipfile:
|
||||||
|
# Delete caches
|
||||||
|
try:
|
||||||
|
files = ZipFile(self.__original_zipfile.path).namelist()
|
||||||
|
for file in files:
|
||||||
|
cache_key = 'problem_archive:%s:%s' % (self.problem.code, get_file_cachekey(file))
|
||||||
|
cache.delete(cache_key)
|
||||||
|
except BadZipFile:
|
||||||
|
pass
|
||||||
|
|
||||||
self.__original_zipfile.delete(save=False)
|
self.__original_zipfile.delete(save=False)
|
||||||
return super(ProblemData, self).save(*args, **kwargs)
|
return super(ProblemData, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
|
@ -136,12 +136,15 @@ class Profile(models.Model):
|
||||||
|
|
||||||
def calculate_points(self, table=_pp_table):
|
def calculate_points(self, table=_pp_table):
|
||||||
from judge.models import Problem
|
from judge.models import Problem
|
||||||
data = (Problem.objects.filter(submission__user=self, submission__points__isnull=False, is_public=True,
|
public_problems = Problem.get_public_problems()
|
||||||
is_organization_private=False)
|
data = (
|
||||||
.annotate(max_points=Max('submission__points')).order_by('-max_points')
|
public_problems.filter(submission__user=self, submission__points__isnull=False)
|
||||||
.values_list('max_points', flat=True).filter(max_points__gt=0))
|
.annotate(max_points=Max('submission__points')).order_by('-max_points')
|
||||||
extradata = Problem.objects.filter(submission__user=self, submission__result='AC', is_public=True) \
|
.values_list('max_points', flat=True).filter(max_points__gt=0)
|
||||||
.values('id').distinct().count()
|
)
|
||||||
|
extradata = (
|
||||||
|
public_problems.filter(submission__user=self, submission__result='AC').values('id').distinct().count()
|
||||||
|
)
|
||||||
bonus_function = settings.DMOJ_PP_BONUS_FUNCTION
|
bonus_function = settings.DMOJ_PP_BONUS_FUNCTION
|
||||||
points = sum(data)
|
points = sum(data)
|
||||||
problems = len(data)
|
problems = len(data)
|
||||||
|
@ -163,8 +166,12 @@ class Profile(models.Model):
|
||||||
remove_contest.alters_data = True
|
remove_contest.alters_data = True
|
||||||
|
|
||||||
def update_contest(self):
|
def update_contest(self):
|
||||||
contest = self.current_contest
|
from judge.models import ContestParticipation
|
||||||
if contest is not None and (contest.ended or not contest.contest.is_accessible_by(self.user)):
|
try:
|
||||||
|
contest = self.current_contest
|
||||||
|
if contest is not None and (contest.ended or not contest.contest.is_accessible_by(self.user)):
|
||||||
|
self.remove_contest()
|
||||||
|
except ContestParticipation.DoesNotExist:
|
||||||
self.remove_contest()
|
self.remove_contest()
|
||||||
|
|
||||||
update_contest.alters_data = True
|
update_contest.alters_data = True
|
||||||
|
@ -254,5 +261,13 @@ class Friend(models.Model):
|
||||||
else:
|
else:
|
||||||
self.make_friend(current_user, new_friend)
|
self.make_friend(current_user, new_friend)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_friend_profiles(self, current_user):
|
||||||
|
try:
|
||||||
|
ret = self.objects.get(current_user=current_user).users.all()
|
||||||
|
except Friend.DoesNotExist:
|
||||||
|
ret = Profile.objects.none()
|
||||||
|
return ret
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.current_user)
|
return str(self.current_user)
|
||||||
|
|
|
@ -97,7 +97,10 @@ class Language(models.Model):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_default_language(cls):
|
def get_default_language(cls):
|
||||||
return Language.objects.get(key=settings.DEFAULT_USER_LANGUAGE)
|
try:
|
||||||
|
return Language.objects.get(key=settings.DEFAULT_USER_LANGUAGE)
|
||||||
|
except Language.DoesNotExist:
|
||||||
|
return cls.get_python3()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_default_language_pk(cls):
|
def get_default_language_pk(cls):
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import base64
|
||||||
import errno
|
import errno
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
@ -10,6 +11,20 @@ import uuid
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import gettext
|
||||||
|
|
||||||
|
logger = logging.getLogger('judge.problem.pdf')
|
||||||
|
|
||||||
|
HAS_SELENIUM = False
|
||||||
|
if settings.USE_SELENIUM:
|
||||||
|
try:
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.common.exceptions import TimeoutException
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
from selenium.webdriver.support import expected_conditions as EC
|
||||||
|
from selenium.webdriver.support.ui import WebDriverWait
|
||||||
|
HAS_SELENIUM = True
|
||||||
|
except ImportError:
|
||||||
|
logger.warning('Failed to import Selenium', exc_info=True)
|
||||||
|
|
||||||
HAS_PHANTOMJS = os.access(settings.PHANTOMJS, os.X_OK)
|
HAS_PHANTOMJS = os.access(settings.PHANTOMJS, os.X_OK)
|
||||||
HAS_SLIMERJS = os.access(settings.SLIMERJS, os.X_OK)
|
HAS_SLIMERJS = os.access(settings.SLIMERJS, os.X_OK)
|
||||||
|
|
||||||
|
@ -18,13 +33,11 @@ PUPPETEER_MODULE = settings.PUPPETEER_MODULE
|
||||||
HAS_PUPPETEER = os.access(NODE_PATH, os.X_OK) and os.path.isdir(PUPPETEER_MODULE)
|
HAS_PUPPETEER = os.access(NODE_PATH, os.X_OK) and os.path.isdir(PUPPETEER_MODULE)
|
||||||
|
|
||||||
HAS_PDF = (os.path.isdir(settings.DMOJ_PDF_PROBLEM_CACHE) and
|
HAS_PDF = (os.path.isdir(settings.DMOJ_PDF_PROBLEM_CACHE) and
|
||||||
(HAS_PHANTOMJS or HAS_SLIMERJS or HAS_PUPPETEER))
|
(HAS_PHANTOMJS or HAS_SLIMERJS or HAS_PUPPETEER or HAS_SELENIUM))
|
||||||
|
|
||||||
EXIFTOOL = settings.EXIFTOOL
|
EXIFTOOL = settings.EXIFTOOL
|
||||||
HAS_EXIFTOOL = os.access(EXIFTOOL, os.X_OK)
|
HAS_EXIFTOOL = os.access(EXIFTOOL, os.X_OK)
|
||||||
|
|
||||||
logger = logging.getLogger('judge.problem.pdf')
|
|
||||||
|
|
||||||
|
|
||||||
class BasePdfMaker(object):
|
class BasePdfMaker(object):
|
||||||
math_engine = 'jax'
|
math_engine = 'jax'
|
||||||
|
@ -240,8 +253,8 @@ puppeteer.launch().then(browser => Promise.resolve()
|
||||||
|
|
||||||
def get_render_script(self):
|
def get_render_script(self):
|
||||||
return self.template.replace('{params}', json.dumps({
|
return self.template.replace('{params}', json.dumps({
|
||||||
'input': 'file://' + os.path.abspath(os.path.join(self.dir, 'input.html')),
|
'input': 'file://%s' % self.htmlfile,
|
||||||
'output': os.path.abspath(os.path.join(self.dir, 'output.pdf')),
|
'output': self.pdffile,
|
||||||
'paper': settings.PUPPETEER_PAPER_SIZE,
|
'paper': settings.PUPPETEER_PAPER_SIZE,
|
||||||
'footer': gettext('Page [page] of [topage]'),
|
'footer': gettext('Page [page] of [topage]'),
|
||||||
}))
|
}))
|
||||||
|
@ -257,9 +270,55 @@ puppeteer.launch().then(browser => Promise.resolve()
|
||||||
self.proc = subprocess.Popen(cmdline, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.dir, env=env)
|
self.proc = subprocess.Popen(cmdline, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=self.dir, env=env)
|
||||||
self.log = self.proc.communicate()[0]
|
self.log = self.proc.communicate()[0]
|
||||||
|
|
||||||
|
class SeleniumPDFRender(BasePdfMaker):
|
||||||
|
success = False
|
||||||
|
template = {
|
||||||
|
'printBackground': True,
|
||||||
|
'displayHeaderFooter': True,
|
||||||
|
'headerTemplate': '<div></div>',
|
||||||
|
'footerTemplate': '<center style="margin: 0 auto; font-family: Segoe UI; font-size: 10px">' +
|
||||||
|
gettext('Page %s of %s') %
|
||||||
|
('<span class="pageNumber"></span>', '<span class="totalPages"></span>') +
|
||||||
|
'</center>',
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_log(self, driver):
|
||||||
|
return '\n'.join(map(str, driver.get_log('driver') + driver.get_log('browser')))
|
||||||
|
|
||||||
|
def _make(self, debug):
|
||||||
|
options = webdriver.ChromeOptions()
|
||||||
|
options.add_argument("--headless")
|
||||||
|
options.add_argument("--no-sandbox") # for root
|
||||||
|
options.binary_location = settings.SELENIUM_CUSTOM_CHROME_PATH
|
||||||
|
|
||||||
|
browser = webdriver.Chrome(settings.SELENIUM_CHROMEDRIVER_PATH, options=options)
|
||||||
|
browser.get('file://%s' % self.htmlfile)
|
||||||
|
self.log = self.get_log(browser)
|
||||||
|
|
||||||
|
try:
|
||||||
|
WebDriverWait(browser, 15).until(EC.presence_of_element_located((By.CLASS_NAME, 'math-loaded')))
|
||||||
|
except TimeoutException:
|
||||||
|
logger.error('PDF math rendering timed out')
|
||||||
|
self.log = self.get_log(browser) + '\nPDF math rendering timed out'
|
||||||
|
browser.quit()
|
||||||
|
return
|
||||||
|
response = browser.execute_cdp_cmd('Page.printToPDF', self.template)
|
||||||
|
self.log = self.get_log(browser)
|
||||||
|
if not response:
|
||||||
|
browser.quit()
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(self.pdffile, 'wb') as f:
|
||||||
|
f.write(base64.b64decode(response['data']))
|
||||||
|
|
||||||
|
self.success = True
|
||||||
|
browser.quit()
|
||||||
|
|
||||||
|
|
||||||
if HAS_PUPPETEER:
|
if HAS_PUPPETEER:
|
||||||
DefaultPdfMaker = PuppeteerPDFRender
|
DefaultPdfMaker = PuppeteerPDFRender
|
||||||
|
elif HAS_SELENIUM:
|
||||||
|
DefaultPdfMaker = SeleniumPDFRender
|
||||||
elif HAS_SLIMERJS:
|
elif HAS_SLIMERJS:
|
||||||
DefaultPdfMaker = SlimerJSPdfMaker
|
DefaultPdfMaker = SlimerJSPdfMaker
|
||||||
elif HAS_PHANTOMJS:
|
elif HAS_PHANTOMJS:
|
||||||
|
|
297
judge/ratings.py
297
judge/ratings.py
|
@ -1,162 +1,197 @@
|
||||||
import math
|
|
||||||
from bisect import bisect
|
from bisect import bisect
|
||||||
from operator import itemgetter
|
from math import pi, sqrt, tanh
|
||||||
|
from operator import attrgetter, itemgetter
|
||||||
|
|
||||||
from django.db import connection, transaction
|
from django.db import transaction
|
||||||
from django.db.models import Count
|
from django.db.models import Count, OuterRef, Subquery
|
||||||
|
from django.db.models.functions import Coalesce
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from judge.utils.ranker import tie_ranker
|
|
||||||
|
BETA2 = 328.33 ** 2
|
||||||
|
RATING_INIT = 1200 # Newcomer's rating when applying the rating floor/ceiling
|
||||||
|
MEAN_INIT = 1400.
|
||||||
|
VAR_INIT = 250**2 * (BETA2 / 212**2)
|
||||||
|
SD_INIT = sqrt(VAR_INIT)
|
||||||
|
VALID_RANGE = MEAN_INIT - 20 * SD_INIT, MEAN_INIT + 20 * SD_INIT
|
||||||
|
VAR_PER_CONTEST = 1219.047619 * (BETA2 / 212**2)
|
||||||
|
VAR_LIM = (sqrt(VAR_PER_CONTEST**2 + 4 * BETA2 * VAR_PER_CONTEST) - VAR_PER_CONTEST) / 2
|
||||||
|
SD_LIM = sqrt(VAR_LIM)
|
||||||
|
TANH_C = sqrt(3) / pi
|
||||||
|
|
||||||
|
|
||||||
def rational_approximation(t):
|
def tie_ranker(iterable, key=attrgetter('points')):
|
||||||
# Abramowitz and Stegun formula 26.2.23.
|
rank = 0
|
||||||
# The absolute value of the error should be less than 4.5 e-4.
|
delta = 1
|
||||||
c = [2.515517, 0.802853, 0.010328]
|
last = None
|
||||||
d = [1.432788, 0.189269, 0.001308]
|
buf = []
|
||||||
numerator = (c[2] * t + c[1]) * t + c[0]
|
for item in iterable:
|
||||||
denominator = ((d[2] * t + d[1]) * t + d[0]) * t + 1.0
|
new = key(item)
|
||||||
return t - numerator / denominator
|
if new != last:
|
||||||
|
for _ in buf:
|
||||||
|
yield rank + (delta - 1) / 2.0
|
||||||
|
rank += delta
|
||||||
|
delta = 0
|
||||||
|
buf = []
|
||||||
|
delta += 1
|
||||||
|
buf.append(item)
|
||||||
|
last = key(item)
|
||||||
|
for _ in buf:
|
||||||
|
yield rank + (delta - 1) / 2.0
|
||||||
|
|
||||||
|
|
||||||
def normal_CDF_inverse(p):
|
def eval_tanhs(tanh_terms, x):
|
||||||
assert 0.0 < p < 1
|
return sum((wt / sd) * tanh((x - mu) / (2 * sd)) for mu, sd, wt in tanh_terms)
|
||||||
|
|
||||||
# See article above for explanation of this section.
|
|
||||||
if p < 0.5:
|
|
||||||
# F^-1(p) = - G^-1(p)
|
|
||||||
return -rational_approximation(math.sqrt(-2.0 * math.log(p)))
|
|
||||||
else:
|
|
||||||
# F^-1(p) = G^-1(1-p)
|
|
||||||
return rational_approximation(math.sqrt(-2.0 * math.log(1.0 - p)))
|
|
||||||
|
|
||||||
|
|
||||||
def WP(RA, RB, VA, VB):
|
def solve(tanh_terms, y_tg, lin_factor=0, bounds=VALID_RANGE):
|
||||||
return (math.erf((RB - RA) / math.sqrt(2 * (VA * VA + VB * VB))) + 1) / 2.0
|
L, R = bounds
|
||||||
|
Ly, Ry = None, None
|
||||||
|
while R - L > 2:
|
||||||
def recalculate_ratings(old_rating, old_volatility, actual_rank, times_rated):
|
x = (L + R) / 2
|
||||||
# actual_rank: 1 is first place, N is last place
|
y = lin_factor * x + eval_tanhs(tanh_terms, x)
|
||||||
# if there are ties, use the average of places (if places 2, 3, 4, 5 tie, use 3.5 for all of them)
|
if y > y_tg:
|
||||||
|
R, Ry = x, y
|
||||||
N = len(old_rating)
|
elif y < y_tg:
|
||||||
new_rating = old_rating[:]
|
L, Ly = x, y
|
||||||
new_volatility = old_volatility[:]
|
|
||||||
if N <= 1:
|
|
||||||
return new_rating, new_volatility
|
|
||||||
|
|
||||||
ranking = list(range(N))
|
|
||||||
ranking.sort(key=old_rating.__getitem__, reverse=True)
|
|
||||||
|
|
||||||
ave_rating = float(sum(old_rating)) / N
|
|
||||||
sum1 = sum(i * i for i in old_volatility) / N
|
|
||||||
sum2 = sum((i - ave_rating) ** 2 for i in old_rating) / (N - 1)
|
|
||||||
CF = math.sqrt(sum1 + sum2)
|
|
||||||
|
|
||||||
for i in range(N):
|
|
||||||
ERank = 0.5
|
|
||||||
for j in range(N):
|
|
||||||
ERank += WP(old_rating[i], old_rating[j], old_volatility[i], old_volatility[j])
|
|
||||||
|
|
||||||
EPerf = -normal_CDF_inverse((ERank - 0.5) / N)
|
|
||||||
APerf = -normal_CDF_inverse((actual_rank[i] - 0.5) / N)
|
|
||||||
PerfAs = old_rating[i] + CF * (APerf - EPerf)
|
|
||||||
Weight = 1.0 / (1 - (0.42 / (times_rated[i] + 1) + 0.18)) - 1.0
|
|
||||||
if old_rating[i] > 2500:
|
|
||||||
Weight *= 0.8
|
|
||||||
elif old_rating[i] >= 2000:
|
|
||||||
Weight *= 0.9
|
|
||||||
|
|
||||||
Cap = 150.0 + 1500.0 / (times_rated[i] + 2)
|
|
||||||
|
|
||||||
new_rating[i] = (old_rating[i] + Weight * PerfAs) / (1.0 + Weight)
|
|
||||||
|
|
||||||
if times_rated[i] == 0:
|
|
||||||
new_volatility[i] = 385
|
|
||||||
else:
|
else:
|
||||||
new_volatility[i] = math.sqrt(((new_rating[i] - old_rating[i]) ** 2) / Weight +
|
return x
|
||||||
(old_volatility[i] ** 2) / (Weight + 1))
|
# Use linear interpolation to be slightly more accurate.
|
||||||
if abs(old_rating[i] - new_rating[i]) > Cap:
|
if Ly is None:
|
||||||
if old_rating[i] < new_rating[i]:
|
Ly = lin_factor * L + eval_tanhs(tanh_terms, L)
|
||||||
new_rating[i] = old_rating[i] + Cap
|
if y_tg <= Ly:
|
||||||
else:
|
return L
|
||||||
new_rating[i] = old_rating[i] - Cap
|
if Ry is None:
|
||||||
|
Ry = lin_factor * R + eval_tanhs(tanh_terms, R)
|
||||||
|
if y_tg >= Ry:
|
||||||
|
return R
|
||||||
|
ratio = (y_tg - Ly) / (Ry - Ly)
|
||||||
|
return L * (1 - ratio) + R * ratio
|
||||||
|
|
||||||
# try to keep the sum of ratings constant
|
|
||||||
adjust = float(sum(old_rating) - sum(new_rating)) / N
|
def get_var(times_ranked, cache=[VAR_INIT]):
|
||||||
new_rating = list(map(adjust.__add__, new_rating))
|
while times_ranked >= len(cache):
|
||||||
# inflate a little if we have to so people who placed first don't lose rating
|
next_var = 1. / (1. / (cache[-1] + VAR_PER_CONTEST) + 1. / BETA2)
|
||||||
best_rank = min(actual_rank)
|
cache.append(next_var)
|
||||||
for i in range(N):
|
return cache[times_ranked]
|
||||||
if abs(actual_rank[i] - best_rank) <= 1e-3 and new_rating[i] < old_rating[i] + 1:
|
|
||||||
new_rating[i] = old_rating[i] + 1
|
|
||||||
return list(map(int, map(round, new_rating))), list(map(int, map(round, new_volatility)))
|
def recalculate_ratings(ranking, old_mean, times_ranked, historical_p):
|
||||||
|
n = len(ranking)
|
||||||
|
new_p = [0.] * n
|
||||||
|
new_mean = [0.] * n
|
||||||
|
|
||||||
|
# Note: pre-multiply delta by TANH_C to improve efficiency.
|
||||||
|
delta = [TANH_C * sqrt(get_var(t) + VAR_PER_CONTEST + BETA2) for t in times_ranked]
|
||||||
|
p_tanh_terms = [(m, d, 1) for m, d in zip(old_mean, delta)]
|
||||||
|
|
||||||
|
# Calculate performance at index i.
|
||||||
|
def solve_idx(i, bounds=VALID_RANGE):
|
||||||
|
r = ranking[i]
|
||||||
|
y_tg = 0
|
||||||
|
for d, s in zip(delta, ranking):
|
||||||
|
if s > r: # s loses to r
|
||||||
|
y_tg += 1. / d
|
||||||
|
elif s < r: # s beats r
|
||||||
|
y_tg -= 1. / d
|
||||||
|
# Otherwise, this is a tie that counts as half a win, as per Elo-MMR.
|
||||||
|
new_p[i] = solve(p_tanh_terms, y_tg, bounds=bounds)
|
||||||
|
|
||||||
|
# Fill all indices between i and j, inclusive. Use the fact that new_p is non-increasing.
|
||||||
|
def divconq(i, j):
|
||||||
|
if j - i > 1:
|
||||||
|
k = (i + j) // 2
|
||||||
|
solve_idx(k, bounds=(new_p[j], new_p[i]))
|
||||||
|
divconq(i, k)
|
||||||
|
divconq(k, j)
|
||||||
|
|
||||||
|
if n < 2:
|
||||||
|
new_p = list(old_mean)
|
||||||
|
new_mean = list(old_mean)
|
||||||
|
else:
|
||||||
|
# Calculate performance.
|
||||||
|
solve_idx(0)
|
||||||
|
solve_idx(n - 1)
|
||||||
|
divconq(0, n - 1)
|
||||||
|
|
||||||
|
# Calculate mean.
|
||||||
|
for i, r in enumerate(ranking):
|
||||||
|
tanh_terms = []
|
||||||
|
w_prev = 1.
|
||||||
|
w_sum = 0.
|
||||||
|
for j, h in enumerate([new_p[i]] + historical_p[i]):
|
||||||
|
gamma2 = (VAR_PER_CONTEST if j > 0 else 0)
|
||||||
|
h_var = get_var(times_ranked[i] + 1 - j)
|
||||||
|
k = h_var / (h_var + gamma2)
|
||||||
|
w = w_prev * k**2
|
||||||
|
# Future optimization: If j is around 20, then w < 1e-3 and it is possible to break early.
|
||||||
|
tanh_terms.append((h, sqrt(BETA2) * TANH_C, w))
|
||||||
|
w_prev = w
|
||||||
|
w_sum += w / BETA2
|
||||||
|
w0 = 1. / get_var(times_ranked[i] + 1) - w_sum
|
||||||
|
p0 = eval_tanhs(tanh_terms[1:], old_mean[i]) / w0 + old_mean[i]
|
||||||
|
new_mean[i] = solve(tanh_terms, w0 * p0, lin_factor=w0)
|
||||||
|
|
||||||
|
# Display a slightly lower rating to incentivize participation.
|
||||||
|
# As times_ranked increases, new_rating converges to new_mean.
|
||||||
|
new_rating = [max(1, round(m - (sqrt(get_var(t + 1)) - SD_LIM))) for m, t in zip(new_mean, times_ranked)]
|
||||||
|
|
||||||
|
return new_rating, new_mean, new_p
|
||||||
|
|
||||||
|
|
||||||
def rate_contest(contest):
|
def rate_contest(contest):
|
||||||
from judge.models import Rating, Profile
|
from judge.models import Rating, Profile
|
||||||
|
|
||||||
cursor = connection.cursor()
|
rating_subquery = Rating.objects.filter(user=OuterRef('user'))
|
||||||
cursor.execute('''
|
rating_sorted = rating_subquery.order_by('-contest__end_time')
|
||||||
SELECT judge_rating.user_id, judge_rating.rating, judge_rating.volatility, r.times
|
users = contest.users.order_by('is_disqualified', '-score', 'cumtime', 'tiebreaker') \
|
||||||
FROM judge_rating INNER JOIN
|
.annotate(submissions=Count('submission'),
|
||||||
judge_contest ON (judge_contest.id = judge_rating.contest_id) INNER JOIN (
|
last_rating=Coalesce(Subquery(rating_sorted.values('rating')[:1]), RATING_INIT),
|
||||||
SELECT judge_rating.user_id AS id, MAX(judge_contest.end_time) AS last_time,
|
last_mean=Coalesce(Subquery(rating_sorted.values('mean')[:1]), MEAN_INIT),
|
||||||
COUNT(judge_rating.user_id) AS times
|
times=Coalesce(Subquery(rating_subquery.order_by().values('user_id')
|
||||||
FROM judge_contestparticipation INNER JOIN
|
.annotate(count=Count('id')).values('count')), 0)) \
|
||||||
judge_rating ON (judge_rating.user_id = judge_contestparticipation.user_id) INNER JOIN
|
.exclude(user_id__in=contest.rate_exclude.all()) \
|
||||||
judge_contest ON (judge_contest.id = judge_rating.contest_id)
|
.filter(virtual=0).values('id', 'user_id', 'score', 'cumtime', 'tiebreaker',
|
||||||
WHERE judge_contestparticipation.contest_id = %s AND judge_contest.end_time < %s AND
|
'last_rating', 'last_mean', 'times')
|
||||||
judge_contestparticipation.user_id NOT IN (
|
|
||||||
SELECT profile_id FROM judge_contest_rate_exclude WHERE contest_id = %s
|
|
||||||
) AND judge_contestparticipation.virtual = 0
|
|
||||||
GROUP BY judge_rating.user_id
|
|
||||||
ORDER BY judge_contestparticipation.score DESC, judge_contestparticipation.cumtime ASC
|
|
||||||
) AS r ON (judge_rating.user_id = r.id AND judge_contest.end_time = r.last_time)
|
|
||||||
''', (contest.id, contest.end_time, contest.id))
|
|
||||||
data = {user: (rating, volatility, times) for user, rating, volatility, times in cursor.fetchall()}
|
|
||||||
cursor.close()
|
|
||||||
|
|
||||||
users = contest.users.order_by('is_disqualified', '-score', 'cumtime').annotate(submissions=Count('submission')) \
|
|
||||||
.exclude(user_id__in=contest.rate_exclude.all()).filter(virtual=0, user__is_unlisted=False) \
|
|
||||||
.values_list('id', 'user_id', 'score', 'cumtime')
|
|
||||||
if not contest.rate_all:
|
if not contest.rate_all:
|
||||||
users = users.filter(submissions__gt=0)
|
users = users.filter(submissions__gt=0)
|
||||||
if contest.rating_floor is not None:
|
if contest.rating_floor is not None:
|
||||||
users = users.exclude(user__rating__lt=contest.rating_floor)
|
users = users.exclude(last_rating__lt=contest.rating_floor)
|
||||||
if contest.rating_ceiling is not None:
|
if contest.rating_ceiling is not None:
|
||||||
users = users.exclude(user__rating__gt=contest.rating_ceiling)
|
users = users.exclude(last_rating__gt=contest.rating_ceiling)
|
||||||
users = list(tie_ranker(users, key=itemgetter(2, 3)))
|
|
||||||
participation_ids = [user[1][0] for user in users]
|
users = list(users)
|
||||||
user_ids = [user[1][1] for user in users]
|
participation_ids = list(map(itemgetter('id'), users))
|
||||||
ranking = list(map(itemgetter(0), users))
|
user_ids = list(map(itemgetter('user_id'), users))
|
||||||
old_data = [data.get(user, (1200, 535, 0)) for user in user_ids]
|
ranking = list(tie_ranker(users, key=itemgetter('score', 'cumtime', 'tiebreaker')))
|
||||||
old_rating = list(map(itemgetter(0), old_data))
|
old_mean = list(map(itemgetter('last_mean'), users))
|
||||||
old_volatility = list(map(itemgetter(1), old_data))
|
times_ranked = list(map(itemgetter('times'), users))
|
||||||
times_ranked = list(map(itemgetter(2), old_data))
|
historical_p = [[] for _ in users]
|
||||||
rating, volatility = recalculate_ratings(old_rating, old_volatility, ranking, times_ranked)
|
|
||||||
|
user_id_to_idx = {uid: i for i, uid in enumerate(user_ids)}
|
||||||
|
for h in Rating.objects.filter(user_id__in=user_ids) \
|
||||||
|
.order_by('-contest__end_time') \
|
||||||
|
.values('user_id', 'performance'):
|
||||||
|
idx = user_id_to_idx[h['user_id']]
|
||||||
|
historical_p[idx].append(h['performance'])
|
||||||
|
|
||||||
|
rating, mean, performance = recalculate_ratings(ranking, old_mean, times_ranked, historical_p)
|
||||||
|
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
ratings = [Rating(user_id=id, contest=contest, rating=r, volatility=v, last_rated=now, participation_id=p, rank=z)
|
ratings = [Rating(user_id=i, contest=contest, rating=r, mean=m, performance=perf,
|
||||||
for id, p, r, v, z in zip(user_ids, participation_ids, rating, volatility, ranking)]
|
last_rated=now, participation_id=pid, rank=z)
|
||||||
cursor = connection.cursor()
|
for i, pid, r, m, perf, z in zip(user_ids, participation_ids, rating, mean, performance, ranking)]
|
||||||
cursor.execute('CREATE TEMPORARY TABLE _profile_rating_update(id integer, rating integer)')
|
|
||||||
cursor.executemany('INSERT INTO _profile_rating_update VALUES (%s, %s)', list(zip(user_ids, rating)))
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
Rating.objects.filter(contest=contest).delete()
|
|
||||||
Rating.objects.bulk_create(ratings)
|
Rating.objects.bulk_create(ratings)
|
||||||
cursor.execute('''
|
|
||||||
UPDATE `%s` p INNER JOIN `_profile_rating_update` tmp ON (p.id = tmp.id)
|
Profile.objects.filter(contest_history__contest=contest, contest_history__virtual=0).update(
|
||||||
SET p.rating = tmp.rating
|
rating=Subquery(Rating.objects.filter(user=OuterRef('id'))
|
||||||
''' % Profile._meta.db_table)
|
.order_by('-contest__end_time').values('rating')[:1]))
|
||||||
cursor.execute('DROP TABLE _profile_rating_update')
|
|
||||||
cursor.close()
|
|
||||||
return old_rating, old_volatility, ranking, times_ranked, rating, volatility
|
|
||||||
|
|
||||||
|
|
||||||
RATING_LEVELS = ['Newbie', 'Amateur', 'Expert', 'Candidate Master', 'Master', 'Grandmaster', 'Target']
|
RATING_LEVELS = ['Newbie', 'Amateur', 'Expert', 'Candidate Master', 'Master', 'Grandmaster', 'Target']
|
||||||
RATING_VALUES = [1000, 1200, 1500, 1800, 2200, 3000]
|
RATING_VALUES = [1000, 1400, 1700, 1900, 2100, 2400, 3000]
|
||||||
RATING_CLASS = ['rate-newbie', 'rate-amateur', 'rate-expert', 'rate-candidate-master',
|
RATING_CLASS = ['rate-newbie', 'rate-amateur', 'rate-specialist', 'rate-expert', 'rate-candidate-master',
|
||||||
'rate-master', 'rate-grandmaster', 'rate-target']
|
'rate-master', 'rate-grandmaster', 'rate-target']
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ class ProblemSitemap(Sitemap):
|
||||||
priority = 0.8
|
priority = 0.8
|
||||||
|
|
||||||
def items(self):
|
def items(self):
|
||||||
return Problem.objects.filter(is_public=True, is_organization_private=False).values_list('code')
|
return Problem.get_public_problems().values_list('code')
|
||||||
|
|
||||||
def location(self, obj):
|
def location(self, obj):
|
||||||
return reverse('problem_detail', args=obj)
|
return reverse('problem_detail', args=obj)
|
||||||
|
|
|
@ -83,7 +83,7 @@ def make_profile(backend, user, response, is_new=False, *args, **kwargs):
|
||||||
if is_new:
|
if is_new:
|
||||||
if not hasattr(user, 'profile'):
|
if not hasattr(user, 'profile'):
|
||||||
profile = Profile(user=user)
|
profile = Profile(user=user)
|
||||||
profile.language = Language.get_python3()
|
profile.language = Language.get_default_language()
|
||||||
logger.info('Info from %s: %s', backend.name, response)
|
logger.info('Info from %s: %s', backend.name, response)
|
||||||
profile.save()
|
profile.save()
|
||||||
form = ProfileForm(instance=profile, user=user)
|
form = ProfileForm(instance=profile, user=user)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
from judge.tasks.contest import *
|
||||||
from judge.tasks.demo import *
|
from judge.tasks.demo import *
|
||||||
from judge.tasks.contest import *
|
from judge.tasks.contest import *
|
||||||
from judge.tasks.submission import *
|
from judge.tasks.submission import *
|
||||||
|
|
|
@ -1,10 +1,6 @@
|
||||||
from django.contrib.auth.models import User
|
from judge.models import SubmissionTestCase, Problem
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from judge.models import SubmissionTestCase, Problem, Profile, Language, Organization
|
|
||||||
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import csv
|
|
||||||
|
|
||||||
def generate_report(problem):
|
def generate_report(problem):
|
||||||
testcases = SubmissionTestCase.objects.filter(submission__problem=problem).all()
|
testcases = SubmissionTestCase.objects.filter(submission__problem=problem).all()
|
||||||
|
@ -22,51 +18,3 @@ def generate_report(problem):
|
||||||
|
|
||||||
for i, _ in sorted(rate.items(), key=lambda x: x[1], reverse=True):
|
for i, _ in sorted(rate.items(), key=lambda x: x[1], reverse=True):
|
||||||
print(i, score[i], total[i], rate[i])
|
print(i, score[i], total[i], rate[i])
|
||||||
|
|
||||||
|
|
||||||
def import_users(csv_file):
|
|
||||||
# 1st row: username, password, name, organization
|
|
||||||
# ... row: a_username, passhere, my_name, organ
|
|
||||||
try:
|
|
||||||
f = open(csv_file, 'r')
|
|
||||||
except OSError:
|
|
||||||
print("Could not open csv file", csv_file)
|
|
||||||
return
|
|
||||||
|
|
||||||
with f:
|
|
||||||
reader = csv.DictReader(f)
|
|
||||||
|
|
||||||
for row in reader:
|
|
||||||
try:
|
|
||||||
username = row['username']
|
|
||||||
pwd = row['password']
|
|
||||||
except Exception:
|
|
||||||
print('username and/or password column missing')
|
|
||||||
print('Make sure your columns are: username, password, name, organization')
|
|
||||||
|
|
||||||
user, created = User.objects.get_or_create(username=username, defaults={
|
|
||||||
'is_active': True,
|
|
||||||
})
|
|
||||||
|
|
||||||
profile, _ = Profile.objects.get_or_create(user=user, defaults={
|
|
||||||
'language': Language.get_python3(),
|
|
||||||
'timezone': settings.DEFAULT_USER_TIME_ZONE,
|
|
||||||
})
|
|
||||||
if created:
|
|
||||||
print('Created user', username)
|
|
||||||
|
|
||||||
if pwd:
|
|
||||||
user.set_password(pwd)
|
|
||||||
elif created:
|
|
||||||
user.set_password('lqdoj')
|
|
||||||
print('User', username, 'missing password, default=lqdoj')
|
|
||||||
|
|
||||||
if 'name' in row.keys() and row['name']:
|
|
||||||
user.first_name = row['name']
|
|
||||||
|
|
||||||
if 'organization' in row.keys() and row['organization']:
|
|
||||||
org = Organization.objects.get(name=row['organization'])
|
|
||||||
profile.organizations.add(org)
|
|
||||||
|
|
||||||
user.save()
|
|
||||||
profile.save()
|
|
||||||
|
|
100
judge/tasks/import_users.py
Normal file
100
judge/tasks/import_users.py
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
import csv
|
||||||
|
from tempfile import mktemp
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
|
from judge.models import Profile, Language, Organization
|
||||||
|
|
||||||
|
|
||||||
|
fields = ['username', 'password', 'name', 'school', 'email', 'organizations']
|
||||||
|
descriptions = ['my_username(edit old one if exist)',
|
||||||
|
'123456 (must have)',
|
||||||
|
'Le Van A (can be empty)',
|
||||||
|
'Le Quy Don (can be empty)',
|
||||||
|
'email@email.com (can be empty)',
|
||||||
|
'org1&org2&org3&... (can be empty - org slug in URL)']
|
||||||
|
|
||||||
|
def csv_to_dict(csv_file):
|
||||||
|
rows = csv.reader(csv_file.read().decode().split('\n'))
|
||||||
|
header = next(rows)
|
||||||
|
header = [i.lower() for i in header]
|
||||||
|
|
||||||
|
if 'username' not in header:
|
||||||
|
return []
|
||||||
|
|
||||||
|
res = []
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if len(row) != len(header):
|
||||||
|
continue
|
||||||
|
cur_dict = {i: '' for i in fields}
|
||||||
|
for i in range(len(header)):
|
||||||
|
if header[i] not in fields:
|
||||||
|
continue
|
||||||
|
cur_dict[header[i]] = row[i]
|
||||||
|
if cur_dict['username']:
|
||||||
|
res.append(cur_dict)
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
# return result log
|
||||||
|
def import_users(users):
|
||||||
|
log = ''
|
||||||
|
for i, row in enumerate(users):
|
||||||
|
cur_log = str(i + 1) + '. '
|
||||||
|
|
||||||
|
username = row['username']
|
||||||
|
cur_log += username + ': '
|
||||||
|
|
||||||
|
pwd = row['password']
|
||||||
|
|
||||||
|
user, created = User.objects.get_or_create(username=username, defaults={
|
||||||
|
'is_active': True,
|
||||||
|
})
|
||||||
|
|
||||||
|
profile, _ = Profile.objects.get_or_create(user=user, defaults={
|
||||||
|
'language': Language.get_python3(),
|
||||||
|
'timezone': settings.DEFAULT_USER_TIME_ZONE,
|
||||||
|
})
|
||||||
|
|
||||||
|
if created:
|
||||||
|
cur_log += 'Create new - '
|
||||||
|
else:
|
||||||
|
cur_log += 'Edit - '
|
||||||
|
|
||||||
|
if pwd:
|
||||||
|
user.set_password(pwd)
|
||||||
|
elif created:
|
||||||
|
user.set_password('lqdoj')
|
||||||
|
cur_log += 'Missing password, set password = lqdoj - '
|
||||||
|
|
||||||
|
if 'name' in row.keys() and row['name']:
|
||||||
|
user.first_name = row['name']
|
||||||
|
|
||||||
|
if 'school' in row.keys() and row['school']:
|
||||||
|
user.last_name = row['school']
|
||||||
|
|
||||||
|
if row['organizations']:
|
||||||
|
orgs = row['organizations'].split('&')
|
||||||
|
added_orgs = []
|
||||||
|
for o in orgs:
|
||||||
|
try:
|
||||||
|
org = Organization.objects.get(slug=o)
|
||||||
|
profile.organizations.add(org)
|
||||||
|
added_orgs.append(org.name)
|
||||||
|
except Organization.DoesNotExist:
|
||||||
|
continue
|
||||||
|
if added_orgs:
|
||||||
|
cur_log += 'Added to ' + ', '.join(added_orgs) + ' - '
|
||||||
|
|
||||||
|
if row['email']:
|
||||||
|
user.email = row['email']
|
||||||
|
|
||||||
|
user.save()
|
||||||
|
profile.save()
|
||||||
|
cur_log += 'Saved\n'
|
||||||
|
log += cur_log
|
||||||
|
log += 'FINISH'
|
||||||
|
|
||||||
|
return log
|
87
judge/utils/fine_uploader.py
Normal file
87
judge/utils/fine_uploader.py
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
# https://github.com/FineUploader/server-examples/blob/master/python/django-fine-uploader
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django import forms
|
||||||
|
from django.forms import ClearableFileInput
|
||||||
|
|
||||||
|
import os, os.path
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
'handle_upload', 'save_upload', 'FineUploadForm', 'FineUploadFileInput'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def combine_chunks(total_parts, total_size, source_folder, dest):
|
||||||
|
if not os.path.exists(os.path.dirname(dest)):
|
||||||
|
os.makedirs(os.path.dirname(dest))
|
||||||
|
|
||||||
|
with open(dest, 'wb+') as destination:
|
||||||
|
for i in range(total_parts):
|
||||||
|
part = os.path.join(source_folder, str(i))
|
||||||
|
with open(part, 'rb') as source:
|
||||||
|
destination.write(source.read())
|
||||||
|
|
||||||
|
|
||||||
|
def save_upload(f, path):
|
||||||
|
if not os.path.exists(os.path.dirname(path)):
|
||||||
|
os.makedirs(os.path.dirname(path))
|
||||||
|
with open(path, 'wb+') as destination:
|
||||||
|
if hasattr(f, 'multiple_chunks') and f.multiple_chunks():
|
||||||
|
for chunk in f.chunks():
|
||||||
|
destination.write(chunk)
|
||||||
|
else:
|
||||||
|
destination.write(f.read())
|
||||||
|
|
||||||
|
|
||||||
|
# pass callback function to post_upload
|
||||||
|
def handle_upload(f, fileattrs, upload_dir, post_upload=None):
|
||||||
|
chunks_dir = os.path.join(tempfile.gettempdir(), 'chunk_upload_tmp')
|
||||||
|
if not os.path.exists(os.path.dirname(chunks_dir)):
|
||||||
|
os.makedirs(os.path.dirname(chunks_dir))
|
||||||
|
chunked = False
|
||||||
|
dest_folder = upload_dir
|
||||||
|
dest = os.path.join(dest_folder, fileattrs['qqfilename'])
|
||||||
|
|
||||||
|
# Chunked
|
||||||
|
if fileattrs.get('qqtotalparts') and int(fileattrs['qqtotalparts']) > 1:
|
||||||
|
chunked = True
|
||||||
|
dest_folder = os.path.join(chunks_dir, fileattrs['qquuid'])
|
||||||
|
dest = os.path.join(dest_folder, fileattrs['qqfilename'], str(fileattrs['qqpartindex']))
|
||||||
|
save_upload(f, dest)
|
||||||
|
|
||||||
|
# If the last chunk has been sent, combine the parts.
|
||||||
|
if chunked and (fileattrs['qqtotalparts'] - 1 == fileattrs['qqpartindex']):
|
||||||
|
combine_chunks(fileattrs['qqtotalparts'],
|
||||||
|
fileattrs['qqtotalfilesize'],
|
||||||
|
source_folder=os.path.dirname(dest),
|
||||||
|
dest=os.path.join(upload_dir, fileattrs['qqfilename']))
|
||||||
|
shutil.rmtree(os.path.dirname(os.path.dirname(dest)))
|
||||||
|
|
||||||
|
if post_upload and (not chunked or fileattrs['qqtotalparts'] - 1 == fileattrs['qqpartindex']):
|
||||||
|
post_upload()
|
||||||
|
|
||||||
|
|
||||||
|
class FineUploadForm(forms.Form):
|
||||||
|
qqfile = forms.FileField()
|
||||||
|
qquuid = forms.CharField()
|
||||||
|
qqfilename = forms.CharField()
|
||||||
|
qqpartindex = forms.IntegerField(required=False)
|
||||||
|
qqchunksize = forms.IntegerField(required=False)
|
||||||
|
qqpartbyteoffset = forms.IntegerField(required=False)
|
||||||
|
qqtotalfilesize = forms.IntegerField(required=False)
|
||||||
|
qqtotalparts = forms.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class FineUploadFileInput(ClearableFileInput):
|
||||||
|
template_name = 'widgets/fine_uploader.html'
|
||||||
|
def fine_uploader_id(self, name):
|
||||||
|
return name + '_' + 'fine_uploader'
|
||||||
|
|
||||||
|
def get_context(self, name, value, attrs):
|
||||||
|
context = super().get_context(name, value, attrs)
|
||||||
|
context['widget'].update({
|
||||||
|
'fine_uploader_id': self.fine_uploader_id(name),
|
||||||
|
})
|
||||||
|
return context
|
|
@ -1,15 +1,17 @@
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
import zipfile
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.files.storage import FileSystemStorage
|
from django.core.files.storage import FileSystemStorage
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
VALIDATOR_TEMPLATE_PATH = 'validator_template/template.py'
|
VALIDATOR_TEMPLATE_PATH = 'validator_template/template.py'
|
||||||
|
|
||||||
|
@ -232,3 +234,61 @@ class ProblemDataCompiler(object):
|
||||||
def generate(cls, *args, **kwargs):
|
def generate(cls, *args, **kwargs):
|
||||||
self = cls(*args, **kwargs)
|
self = cls(*args, **kwargs)
|
||||||
self.compile()
|
self.compile()
|
||||||
|
|
||||||
|
|
||||||
|
def get_visible_content(data):
|
||||||
|
data = data or b''
|
||||||
|
data = data.replace(b'\r\n', b'\r').replace(b'\r', b'\n')
|
||||||
|
|
||||||
|
data = data.decode('utf-8')
|
||||||
|
|
||||||
|
if (len(data) > settings.TESTCASE_VISIBLE_LENGTH):
|
||||||
|
data = data[:settings.TESTCASE_VISIBLE_LENGTH]
|
||||||
|
data += '.' * 3
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_cachekey(file):
|
||||||
|
return hashlib.sha1(file.encode()).hexdigest()
|
||||||
|
|
||||||
|
def get_problem_case(problem, files):
|
||||||
|
result = {}
|
||||||
|
uncached_files = []
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
cache_key = 'problem_archive:%s:%s' % (problem.code, get_file_cachekey(file))
|
||||||
|
qs = cache.get(cache_key)
|
||||||
|
if qs is None:
|
||||||
|
uncached_files.append(file)
|
||||||
|
else:
|
||||||
|
result[file] = qs
|
||||||
|
|
||||||
|
if not uncached_files:
|
||||||
|
return result
|
||||||
|
|
||||||
|
archive_path = os.path.join(settings.DMOJ_PROBLEM_DATA_ROOT,
|
||||||
|
str(problem.data_files.zipfile))
|
||||||
|
if not os.path.exists(archive_path):
|
||||||
|
raise Exception(
|
||||||
|
'archive file "%s" does not exist' % archive_path)
|
||||||
|
try:
|
||||||
|
archive = zipfile.ZipFile(archive_path, 'r')
|
||||||
|
except zipfile.BadZipfile:
|
||||||
|
raise Exception('bad archive: "%s"' % archive_path)
|
||||||
|
|
||||||
|
for file in uncached_files:
|
||||||
|
cache_key = 'problem_archive:%s:%s' % (problem.code, get_file_cachekey(file))
|
||||||
|
with archive.open(file) as f:
|
||||||
|
s = f.read(settings.TESTCASE_VISIBLE_LENGTH + 3)
|
||||||
|
# add this so there are no characters left behind (ex, 'á' = 2 utf-8 chars)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
s.decode('utf-8')
|
||||||
|
break
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
s += f.read(1)
|
||||||
|
qs = get_visible_content(s)
|
||||||
|
cache.set(cache_key, qs, 86400)
|
||||||
|
result[file] = qs
|
||||||
|
|
||||||
|
return result
|
|
@ -1,6 +1,8 @@
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from math import e
|
from math import e
|
||||||
|
import os, zipfile
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import Case, Count, ExpressionWrapper, F, Max, Q, When
|
from django.db.models import Case, Count, ExpressionWrapper, F, Max, Q, When
|
||||||
from django.db.models.fields import FloatField
|
from django.db.models.fields import FloatField
|
||||||
|
@ -112,8 +114,8 @@ def hot_problems(duration, limit):
|
||||||
cache_key = 'hot_problems:%d:%d' % (duration.total_seconds(), limit)
|
cache_key = 'hot_problems:%d:%d' % (duration.total_seconds(), limit)
|
||||||
qs = cache.get(cache_key)
|
qs = cache.get(cache_key)
|
||||||
if qs is None:
|
if qs is None:
|
||||||
qs = Problem.objects.filter(is_public=True, is_organization_private=False,
|
qs = Problem.get_public_problems() \
|
||||||
submission__date__gt=timezone.now() - duration, points__gt=3, points__lt=25)
|
.filter(submission__date__gt=timezone.now() - duration, points__gt=3, points__lt=25)
|
||||||
qs0 = qs.annotate(k=Count('submission__user', distinct=True)).order_by('-k').values_list('k', flat=True)
|
qs0 = qs.annotate(k=Count('submission__user', distinct=True)).order_by('-k').values_list('k', flat=True)
|
||||||
|
|
||||||
if not qs0:
|
if not qs0:
|
||||||
|
|
|
@ -13,22 +13,3 @@ def ranker(iterable, key=attrgetter('points'), rank=0):
|
||||||
yield rank, item
|
yield rank, item
|
||||||
last = key(item)
|
last = key(item)
|
||||||
|
|
||||||
|
|
||||||
def tie_ranker(iterable, key=attrgetter('points')):
|
|
||||||
rank = 0
|
|
||||||
delta = 1
|
|
||||||
last = None
|
|
||||||
buf = []
|
|
||||||
for item in iterable:
|
|
||||||
new = key(item)
|
|
||||||
if new != last:
|
|
||||||
for i in buf:
|
|
||||||
yield rank + (delta - 1) / 2.0, i
|
|
||||||
rank += delta
|
|
||||||
delta = 0
|
|
||||||
buf = []
|
|
||||||
delta += 1
|
|
||||||
buf.append(item)
|
|
||||||
last = key(item)
|
|
||||||
for i in buf:
|
|
||||||
yield rank + (delta - 1) / 2.0, i
|
|
||||||
|
|
|
@ -51,3 +51,18 @@ def get_bar_chart(data, **kwargs):
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def get_histogram(data, **kwargs):
|
||||||
|
return {
|
||||||
|
'labels': [round(i, 1) for i in list(map(itemgetter(0), data))],
|
||||||
|
'datasets': [
|
||||||
|
{
|
||||||
|
'backgroundColor': kwargs.get('fillColor', 'rgba(151,187,205,0.5)'),
|
||||||
|
'borderColor': kwargs.get('strokeColor', 'rgba(151,187,205,0.8)'),
|
||||||
|
'borderWidth': 1,
|
||||||
|
'hoverBackgroundColor': kwargs.get('highlightFill', 'rgba(151,187,205,0.75)'),
|
||||||
|
'hoverBorderColor': kwargs.get('highlightStroke', 'rgba(151,187,205,1)'),
|
||||||
|
'data': list(map(itemgetter(1), data)),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
|
@ -4,7 +4,7 @@ from django.views.generic import FormView
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
|
||||||
from judge.utils.diggpaginator import DiggPaginator
|
from judge.utils.diggpaginator import DiggPaginator
|
||||||
|
from django.utils.html import mark_safe
|
||||||
|
|
||||||
def class_view_decorator(function_decorator):
|
def class_view_decorator(function_decorator):
|
||||||
"""Convert a function based decorator into a class based decorator usable
|
"""Convert a function based decorator into a class based decorator usable
|
||||||
|
|
|
@ -16,9 +16,8 @@ def sane_time_repr(delta):
|
||||||
|
|
||||||
|
|
||||||
def api_v1_contest_list(request):
|
def api_v1_contest_list(request):
|
||||||
queryset = Contest.objects.filter(is_visible=True, is_private=False,
|
queryset = Contest.get_visible_contests(request.user).prefetch_related(
|
||||||
is_organization_private=False).prefetch_related(
|
Prefetch('tags', queryset=ContestTag.objects.only('name'), to_attr='tag_list'))
|
||||||
Prefetch('tags', queryset=ContestTag.objects.only('name'), to_attr='tag_list')).defer('description')
|
|
||||||
|
|
||||||
return JsonResponse({c.key: {
|
return JsonResponse({c.key: {
|
||||||
'name': c.name,
|
'name': c.name,
|
||||||
|
@ -33,13 +32,10 @@ def api_v1_contest_detail(request, contest):
|
||||||
contest = get_object_or_404(Contest, key=contest)
|
contest = get_object_or_404(Contest, key=contest)
|
||||||
|
|
||||||
in_contest = contest.is_in_contest(request.user)
|
in_contest = contest.is_in_contest(request.user)
|
||||||
can_see_rankings = contest.can_see_scoreboard(request.user)
|
can_see_rankings = contest.can_see_full_scoreboard(request.user)
|
||||||
if contest.hide_scoreboard and in_contest:
|
|
||||||
can_see_rankings = False
|
|
||||||
|
|
||||||
problems = list(contest.contest_problems.select_related('problem')
|
problems = list(contest.contest_problems.select_related('problem')
|
||||||
.defer('problem__description').order_by('order'))
|
.defer('problem__description').order_by('order'))
|
||||||
participations = (contest.users.filter(virtual=0, user__is_unlisted=False)
|
participations = (contest.users.filter(virtual=0)
|
||||||
.prefetch_related('user__organizations')
|
.prefetch_related('user__organizations')
|
||||||
.annotate(username=F('user__user__username'))
|
.annotate(username=F('user__user__username'))
|
||||||
.order_by('-score', 'cumtime') if can_see_rankings else [])
|
.order_by('-score', 'cumtime') if can_see_rankings else [])
|
||||||
|
@ -138,20 +134,20 @@ def api_v1_user_info(request, user):
|
||||||
last_rating = profile.ratings.last()
|
last_rating = profile.ratings.last()
|
||||||
|
|
||||||
contest_history = {}
|
contest_history = {}
|
||||||
if not profile.is_unlisted:
|
participations = ContestParticipation.objects.filter(user=profile, virtual=0, contest__is_visible=True,
|
||||||
participations = ContestParticipation.objects.filter(user=profile, virtual=0, contest__is_visible=True,
|
contest__is_private=False,
|
||||||
contest__is_private=False,
|
contest__is_organization_private=False)
|
||||||
contest__is_organization_private=False)
|
for contest_key, rating, mean, performance in participations.values_list(
|
||||||
for contest_key, rating, volatility in participations.values_list('contest__key', 'rating__rating',
|
'contest__key', 'rating__rating', 'rating__mean', 'rating__performance',
|
||||||
'rating__volatility'):
|
):
|
||||||
contest_history[contest_key] = {
|
contest_history[contest_key] = {
|
||||||
'rating': rating,
|
'rating': rating,
|
||||||
'volatility': volatility,
|
'raw_rating': mean,
|
||||||
}
|
'performance': performance,
|
||||||
|
}
|
||||||
|
|
||||||
resp['contests'] = {
|
resp['contests'] = {
|
||||||
'current_rating': last_rating.rating if last_rating else None,
|
'current_rating': last_rating.rating if last_rating else None,
|
||||||
'volatility': last_rating.volatility if last_rating else None,
|
|
||||||
'history': contest_history,
|
'history': contest_history,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -89,7 +89,6 @@ def api_v2_user_info(request):
|
||||||
|
|
||||||
resp['contests'] = {
|
resp['contests'] = {
|
||||||
"current_rating": last_rating[0].rating if last_rating else None,
|
"current_rating": last_rating[0].rating if last_rating else None,
|
||||||
"volatility": last_rating[0].volatility if last_rating else None,
|
|
||||||
'history': contest_history,
|
'history': contest_history,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -57,7 +57,8 @@ class PostList(ListView):
|
||||||
clarifications = ProblemClarification.objects.filter(problem__in=participation.contest.problems.all())
|
clarifications = ProblemClarification.objects.filter(problem__in=participation.contest.problems.all())
|
||||||
context['has_clarifications'] = clarifications.count() > 0
|
context['has_clarifications'] = clarifications.count() > 0
|
||||||
context['clarifications'] = clarifications.order_by('-date')
|
context['clarifications'] = clarifications.order_by('-date')
|
||||||
|
if participation.contest.is_editable_by(self.request.user):
|
||||||
|
context['can_edit_contest'] = True
|
||||||
context['user_count'] = lazy(Profile.objects.count, int, int)
|
context['user_count'] = lazy(Profile.objects.count, int, int)
|
||||||
context['problem_count'] = lazy(Problem.objects.filter(is_public=True).count, int, int)
|
context['problem_count'] = lazy(Problem.objects.filter(is_public=True).count, int, int)
|
||||||
context['submission_count'] = lazy(Submission.objects.count, int, int)
|
context['submission_count'] = lazy(Submission.objects.count, int, int)
|
||||||
|
@ -82,16 +83,13 @@ class PostList(ListView):
|
||||||
.order_by('-latest')
|
.order_by('-latest')
|
||||||
[:settings.DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT])
|
[:settings.DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT])
|
||||||
|
|
||||||
visible_contests = Contest.objects.filter(is_visible=True).order_by('start_time')
|
visible_contests = Contest.get_visible_contests(self.request.user).filter(is_visible=True) \
|
||||||
q = Q(is_private=False, is_organization_private=False)
|
.order_by('start_time')
|
||||||
if self.request.user.is_authenticated:
|
|
||||||
q |= Q(is_organization_private=True, organizations__in=user.organizations.all())
|
|
||||||
q |= Q(is_private=True, private_contestants=user)
|
|
||||||
q |= Q(view_contest_scoreboard=user)
|
|
||||||
visible_contests = visible_contests.filter(q)
|
|
||||||
context['current_contests'] = visible_contests.filter(start_time__lte=now, end_time__gt=now).distinct()
|
|
||||||
context['future_contests'] = visible_contests.filter(start_time__gt=now).distinct()
|
|
||||||
|
|
||||||
|
context['current_contests'] = visible_contests.filter(start_time__lte=now, end_time__gt=now)
|
||||||
|
context['future_contests'] = visible_contests.filter(start_time__gt=now)
|
||||||
|
|
||||||
|
visible_contests = Contest.get_visible_contests(self.request.user).filter(is_visible=True)
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
profile = self.request.profile
|
profile = self.request.profile
|
||||||
context['own_open_tickets'] = (Ticket.objects.filter(Q(user=profile) | Q(assignees__in=[profile]), is_open=True).order_by('-id')
|
context['own_open_tickets'] = (Ticket.objects.filter(Q(user=profile) | Q(assignees__in=[profile]), is_open=True).order_by('-id')
|
||||||
|
@ -107,6 +105,7 @@ class PostList(ListView):
|
||||||
context['open_tickets'] = filter_visible_tickets(tickets, self.request.user, profile)[:10]
|
context['open_tickets'] = filter_visible_tickets(tickets, self.request.user, profile)[:10]
|
||||||
else:
|
else:
|
||||||
context['open_tickets'] = []
|
context['open_tickets'] = []
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
from calendar import Calendar, SUNDAY
|
from calendar import Calendar, SUNDAY
|
||||||
from collections import defaultdict, namedtuple
|
from collections import defaultdict, namedtuple
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
|
@ -12,15 +13,15 @@ from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMix
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
|
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Case, Count, FloatField, IntegerField, Max, Min, Q, Sum, Value, When
|
from django.db.models import Case, Count, F, FloatField, IntegerField, Max, Min, Q, Sum, Value, When
|
||||||
from django.db.models.expressions import CombinedExpression
|
from django.db.models.expressions import CombinedExpression
|
||||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect
|
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.template.defaultfilters import date as date_filter
|
from django.template.defaultfilters import date as date_filter
|
||||||
from django.urls import reverse
|
from django.urls import reverse, reverse_lazy
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html, escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.timezone import make_aware
|
from django.utils.timezone import make_aware
|
||||||
from django.utils.translation import gettext as _, gettext_lazy
|
from django.utils.translation import gettext as _, gettext_lazy
|
||||||
|
@ -31,19 +32,21 @@ from judge import event_poster as event
|
||||||
from judge.comments import CommentedDetailView
|
from judge.comments import CommentedDetailView
|
||||||
from judge.forms import ContestCloneForm
|
from judge.forms import ContestCloneForm
|
||||||
from judge.models import Contest, ContestMoss, ContestParticipation, ContestProblem, ContestTag, \
|
from judge.models import Contest, ContestMoss, ContestParticipation, ContestProblem, ContestTag, \
|
||||||
Organization, Problem, Profile, Submission
|
Organization, Problem, Profile, Submission, ProblemClarification
|
||||||
from judge.tasks import run_moss
|
from judge.tasks import run_moss
|
||||||
from judge.utils.celery import redirect_to_task_status
|
from judge.utils.celery import redirect_to_task_status
|
||||||
from judge.utils.opengraph import generate_opengraph
|
from judge.utils.opengraph import generate_opengraph
|
||||||
from judge.utils.problems import _get_result_data
|
from judge.utils.problems import _get_result_data
|
||||||
from judge.utils.ranker import ranker
|
from judge.utils.ranker import ranker
|
||||||
from judge.utils.stats import get_bar_chart, get_pie_chart
|
from judge.utils.stats import get_bar_chart, get_pie_chart, get_histogram
|
||||||
from judge.utils.views import DiggPaginatorMixin, SingleObjectFormView, TitleMixin, generic_message
|
from judge.utils.views import DiggPaginatorMixin, QueryStringSortMixin, SingleObjectFormView, TitleMixin, \
|
||||||
|
generic_message
|
||||||
|
from judge.widgets import HeavyPreviewPageDownWidget
|
||||||
|
|
||||||
__all__ = ['ContestList', 'ContestDetail', 'ContestRanking', 'ContestJoin', 'ContestLeave', 'ContestCalendar',
|
__all__ = ['ContestList', 'ContestDetail', 'ContestRanking', 'ContestJoin', 'ContestLeave', 'ContestCalendar',
|
||||||
'ContestClone', 'ContestStats', 'ContestMossView', 'ContestMossDelete', 'contest_ranking_ajax',
|
'ContestClone', 'ContestStats', 'ContestMossView', 'ContestMossDelete', 'contest_ranking_ajax',
|
||||||
'ContestParticipationList', 'ContestParticipationDisqualify', 'get_contest_ranking_list',
|
'ContestParticipationList', 'ContestParticipationDisqualify', 'get_contest_ranking_list',
|
||||||
'base_contest_ranking_list']
|
'base_contest_ranking_list', 'ContestClarificationView']
|
||||||
|
|
||||||
|
|
||||||
def _find_contest(request, key, private_check=True):
|
def _find_contest(request, key, private_check=True):
|
||||||
|
@ -59,29 +62,18 @@ def _find_contest(request, key, private_check=True):
|
||||||
|
|
||||||
class ContestListMixin(object):
|
class ContestListMixin(object):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = Contest.objects.all()
|
return Contest.get_visible_contests(self.request.user)
|
||||||
if not self.request.user.has_perm('judge.see_private_contest'):
|
|
||||||
q = Q(is_visible=True)
|
|
||||||
if self.request.user.is_authenticated:
|
|
||||||
q |= Q(organizers=self.request.profile)
|
|
||||||
queryset = queryset.filter(q)
|
|
||||||
if not self.request.user.has_perm('judge.edit_all_contest'):
|
|
||||||
q = Q(is_private=False, is_organization_private=False)
|
|
||||||
if self.request.user.is_authenticated:
|
|
||||||
q |= Q(is_organization_private=True, organizations__in=self.request.profile.organizations.all())
|
|
||||||
q |= Q(is_private=True, private_contestants=self.request.profile)
|
|
||||||
q |= Q(view_contest_scoreboard=self.request.profile)
|
|
||||||
queryset = queryset.filter(q)
|
|
||||||
return queryset.distinct()
|
|
||||||
|
|
||||||
|
|
||||||
class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
|
class ContestList(QueryStringSortMixin, DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
|
||||||
model = Contest
|
model = Contest
|
||||||
paginate_by = 20
|
paginate_by = 20
|
||||||
template_name = 'contest/list.html'
|
template_name = 'contest/list.html'
|
||||||
title = gettext_lazy('Contests')
|
title = gettext_lazy('Contests')
|
||||||
context_object_name = 'past_contests'
|
context_object_name = 'past_contests'
|
||||||
first_page_href = None
|
all_sorts = frozenset(('name', 'user_count', 'start_time'))
|
||||||
|
default_desc = frozenset(('name', 'user_count'))
|
||||||
|
default_sort = '-start_time'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def _now(self):
|
def _now(self):
|
||||||
|
@ -101,7 +93,7 @@ class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
|
||||||
|
|
||||||
def _get_queryset(self):
|
def _get_queryset(self):
|
||||||
queryset = super(ContestList, self).get_queryset() \
|
queryset = super(ContestList, self).get_queryset() \
|
||||||
.order_by('-start_time', 'key').prefetch_related('tags', 'organizations', 'organizers')
|
.prefetch_related('tags', 'organizations', 'authors', 'curators', 'testers')
|
||||||
|
|
||||||
if 'contest' in self.request.GET:
|
if 'contest' in self.request.GET:
|
||||||
self.contest_query = query = ' '.join(self.request.GET.getlist('contest')).strip()
|
self.contest_query = query = ' '.join(self.request.GET.getlist('contest')).strip()
|
||||||
|
@ -114,7 +106,7 @@ class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self._get_queryset().filter(end_time__lt=self._now)
|
return self._get_queryset().order_by(self.order, 'key').filter(end_time__lt=self._now)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(ContestList, self).get_context_data(**kwargs)
|
context = super(ContestList, self).get_context_data(**kwargs)
|
||||||
|
@ -128,12 +120,15 @@ class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
for participation in ContestParticipation.objects.filter(virtual=0, user=self.request.profile,
|
for participation in ContestParticipation.objects.filter(virtual=0, user=self.request.profile,
|
||||||
contest_id__in=present) \
|
contest_id__in=present) \
|
||||||
.select_related('contest').prefetch_related('contest__organizers'):
|
.select_related('contest') \
|
||||||
|
.prefetch_related('contest__authors', 'contest__curators', 'contest__testers')\
|
||||||
|
.annotate(key=F('contest__key')):
|
||||||
if not participation.ended:
|
if not participation.ended:
|
||||||
active.append(participation)
|
active.append(participation)
|
||||||
present.remove(participation.contest)
|
present.remove(participation.contest)
|
||||||
|
|
||||||
active.sort(key=attrgetter('end_time'))
|
active.sort(key=attrgetter('end_time', 'key'))
|
||||||
|
present.sort(key=attrgetter('end_time', 'key'))
|
||||||
future.sort(key=attrgetter('start_time'))
|
future.sort(key=attrgetter('start_time'))
|
||||||
context['active_participations'] = active
|
context['active_participations'] = active
|
||||||
context['current_contests'] = present
|
context['current_contests'] = present
|
||||||
|
@ -143,9 +138,8 @@ class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView):
|
||||||
context['contest_query'] = self.contest_query
|
context['contest_query'] = self.contest_query
|
||||||
context['org_query'] = self.org_query
|
context['org_query'] = self.org_query
|
||||||
context['organizations'] = Organization.objects.all()
|
context['organizations'] = Organization.objects.all()
|
||||||
context['page_suffix'] = suffix = (
|
context.update(self.get_sort_context())
|
||||||
'?' + self.request.GET.urlencode()) if self.request.GET else ''
|
context.update(self.get_sort_paginate_context())
|
||||||
context['first_page_href'] = (self.first_page_href or '.') + suffix
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -164,37 +158,44 @@ class ContestMixin(object):
|
||||||
slug_url_kwarg = 'contest'
|
slug_url_kwarg = 'contest'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def is_organizer(self):
|
def is_editor(self):
|
||||||
return self.check_organizer()
|
if not self.request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
return self.request.profile.id in self.object.editor_ids
|
||||||
|
|
||||||
def check_organizer(self, contest=None, user=None):
|
@cached_property
|
||||||
if user is None:
|
def is_tester(self):
|
||||||
user = self.request.user
|
if not self.request.user.is_authenticated:
|
||||||
return (contest or self.object).is_editable_by(user)
|
return False
|
||||||
|
return self.request.profile.id in self.object.tester_ids
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def can_edit(self):
|
||||||
|
return self.object.is_editable_by(self.request.user)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(ContestMixin, self).get_context_data(**kwargs)
|
context = super(ContestMixin, self).get_context_data(**kwargs)
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
profile = self.request.profile
|
try:
|
||||||
in_contest = context['in_contest'] = (profile.current_contest is not None and
|
context['live_participation'] = (
|
||||||
profile.current_contest.contest == self.object)
|
self.request.profile.contest_history.get(
|
||||||
if in_contest:
|
contest=self.object,
|
||||||
context['participation'] = profile.current_contest
|
virtual=ContestParticipation.LIVE,
|
||||||
context['participating'] = True
|
)
|
||||||
|
)
|
||||||
|
except ContestParticipation.DoesNotExist:
|
||||||
|
context['live_participation'] = None
|
||||||
|
context['has_joined'] = False
|
||||||
else:
|
else:
|
||||||
try:
|
context['has_joined'] = True
|
||||||
context['participation'] = profile.contest_history.get(contest=self.object, virtual=0)
|
|
||||||
except ContestParticipation.DoesNotExist:
|
|
||||||
context['participating'] = False
|
|
||||||
context['participation'] = None
|
|
||||||
else:
|
|
||||||
context['participating'] = True
|
|
||||||
else:
|
else:
|
||||||
context['participating'] = False
|
context['live_participation'] = None
|
||||||
context['participation'] = None
|
context['has_joined'] = False
|
||||||
context['in_contest'] = False
|
|
||||||
context['now'] = timezone.now()
|
context['now'] = timezone.now()
|
||||||
context['is_organizer'] = self.is_organizer
|
context['is_editor'] = self.is_editor
|
||||||
|
context['is_tester'] = self.is_tester
|
||||||
|
context['can_edit'] = self.can_edit
|
||||||
|
|
||||||
if not self.object.og_image or not self.object.summary:
|
if not self.object.og_image or not self.object.summary:
|
||||||
metadata = generate_opengraph('generated-meta-contest:%d' % self.object.id,
|
metadata = generate_opengraph('generated-meta-contest:%d' % self.object.id,
|
||||||
|
@ -210,17 +211,21 @@ class ContestMixin(object):
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
contest = super(ContestMixin, self).get_object(queryset)
|
contest = super(ContestMixin, self).get_object(queryset)
|
||||||
user = self.request.user
|
|
||||||
profile = self.request.profile
|
profile = self.request.profile
|
||||||
|
|
||||||
if (profile is not None and
|
if (profile is not None and
|
||||||
ContestParticipation.objects.filter(id=profile.current_contest_id, contest_id=contest.id).exists()):
|
ContestParticipation.objects.filter(id=profile.current_contest_id, contest_id=contest.id).exists()):
|
||||||
return contest
|
return contest
|
||||||
|
|
||||||
if not contest.is_visible and not user.has_perm('judge.see_private_contest') and (
|
try:
|
||||||
not user.has_perm('judge.edit_own_contest') or
|
contest.access_check(self.request.user)
|
||||||
not self.check_organizer(contest, user)):
|
except Contest.PrivateContest:
|
||||||
|
raise PrivateContestError(contest.name, contest.is_private, contest.is_organization_private,
|
||||||
|
contest.organizations.all())
|
||||||
|
except Contest.Inaccessible:
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
else:
|
||||||
|
return contest
|
||||||
|
|
||||||
if contest.is_private or contest.is_organization_private:
|
if contest.is_private or contest.is_organization_private:
|
||||||
private_contest_error = PrivateContestError(contest.name, contest.is_private,
|
private_contest_error = PrivateContestError(contest.name, contest.is_private,
|
||||||
|
@ -297,7 +302,7 @@ class ContestClone(ContestMixin, PermissionRequiredMixin, TitleMixin, SingleObje
|
||||||
contest.organizations.set(organizations)
|
contest.organizations.set(organizations)
|
||||||
contest.private_contestants.set(private_contestants)
|
contest.private_contestants.set(private_contestants)
|
||||||
contest.view_contest_scoreboard.set(view_contest_scoreboard)
|
contest.view_contest_scoreboard.set(view_contest_scoreboard)
|
||||||
contest.organizers.add(self.request.profile)
|
contest.authors.add(self.request.profile)
|
||||||
|
|
||||||
for problem in contest_problems:
|
for problem in contest_problems:
|
||||||
problem.contest = contest
|
problem.contest = contest
|
||||||
|
@ -337,7 +342,7 @@ class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView):
|
||||||
def join_contest(self, request, access_code=None):
|
def join_contest(self, request, access_code=None):
|
||||||
contest = self.object
|
contest = self.object
|
||||||
|
|
||||||
if not contest.can_join and not self.is_organizer:
|
if not contest.can_join and not (self.is_editor or self.is_tester):
|
||||||
return generic_message(request, _('Contest not ongoing'),
|
return generic_message(request, _('Contest not ongoing'),
|
||||||
_('"%s" is not currently ongoing.') % contest.name)
|
_('"%s" is not currently ongoing.') % contest.name)
|
||||||
|
|
||||||
|
@ -351,8 +356,7 @@ class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView):
|
||||||
_('You have been declared persona non grata for this contest. '
|
_('You have been declared persona non grata for this contest. '
|
||||||
'You are permanently barred from joining this contest.'))
|
'You are permanently barred from joining this contest.'))
|
||||||
|
|
||||||
requires_access_code = (not (request.user.is_superuser or self.is_organizer) and
|
requires_access_code = (not self.can_edit and contest.access_code and access_code != contest.access_code)
|
||||||
contest.access_code and access_code != contest.access_code)
|
|
||||||
if contest.ended:
|
if contest.ended:
|
||||||
if requires_access_code:
|
if requires_access_code:
|
||||||
raise ContestAccessDenied()
|
raise ContestAccessDenied()
|
||||||
|
@ -371,22 +375,24 @@ class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView):
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
|
SPECTATE = ContestParticipation.SPECTATE
|
||||||
|
LIVE = ContestParticipation.LIVE
|
||||||
try:
|
try:
|
||||||
participation = ContestParticipation.objects.get(
|
participation = ContestParticipation.objects.get(
|
||||||
contest=contest, user=profile, virtual=(-1 if self.is_organizer else 0),
|
contest=contest, user=profile, virtual=(SPECTATE if self.is_editor or self.is_tester else LIVE),
|
||||||
)
|
)
|
||||||
except ContestParticipation.DoesNotExist:
|
except ContestParticipation.DoesNotExist:
|
||||||
if requires_access_code:
|
if requires_access_code:
|
||||||
raise ContestAccessDenied()
|
raise ContestAccessDenied()
|
||||||
|
|
||||||
participation = ContestParticipation.objects.create(
|
participation = ContestParticipation.objects.create(
|
||||||
contest=contest, user=profile, virtual=(-1 if self.is_organizer else 0),
|
contest=contest, user=profile, virtual=(SPECTATE if self.is_editor or self.is_tester else LIVE),
|
||||||
real_start=timezone.now(),
|
real_start=timezone.now(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if participation.ended:
|
if participation.ended:
|
||||||
participation = ContestParticipation.objects.get_or_create(
|
participation = ContestParticipation.objects.get_or_create(
|
||||||
contest=contest, user=profile, virtual=-1,
|
contest=contest, user=profile, virtual=SPECTATE,
|
||||||
defaults={'real_start': timezone.now()},
|
defaults={'real_start': timezone.now()},
|
||||||
)[0]
|
)[0]
|
||||||
|
|
||||||
|
@ -449,7 +455,7 @@ class ContestCalendar(TitleMixin, ContestListMixin, TemplateView):
|
||||||
def get_contest_data(self, start, end):
|
def get_contest_data(self, start, end):
|
||||||
end += timedelta(days=1)
|
end += timedelta(days=1)
|
||||||
contests = self.get_queryset().filter(Q(start_time__gte=start, start_time__lt=end) |
|
contests = self.get_queryset().filter(Q(start_time__gte=start, start_time__lt=end) |
|
||||||
Q(end_time__gte=start, end_time__lt=end)).defer('description')
|
Q(end_time__gte=start, end_time__lt=end))
|
||||||
starts, ends, oneday = (defaultdict(list) for i in range(3))
|
starts, ends, oneday = (defaultdict(list) for i in range(3))
|
||||||
for contest in contests:
|
for contest in contests:
|
||||||
start_date = timezone.localtime(contest.start_time).date()
|
start_date = timezone.localtime(contest.start_time).date()
|
||||||
|
@ -523,6 +529,7 @@ class CachedContestCalendar(ContestCalendar):
|
||||||
|
|
||||||
class ContestStats(TitleMixin, ContestMixin, DetailView):
|
class ContestStats(TitleMixin, ContestMixin, DetailView):
|
||||||
template_name = 'contest/stats.html'
|
template_name = 'contest/stats.html'
|
||||||
|
POINT_BIN = 10 # in point distribution
|
||||||
|
|
||||||
def get_title(self):
|
def get_title(self):
|
||||||
return _('%s Statistics') % self.object.name
|
return _('%s Statistics') % self.object.name
|
||||||
|
@ -530,7 +537,7 @@ class ContestStats(TitleMixin, ContestMixin, DetailView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
if not (self.object.ended or self.object.is_editable_by(self.request.user)):
|
if not (self.object.ended or self.can_edit):
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
|
||||||
queryset = Submission.objects.filter(contest_object=self.object)
|
queryset = Submission.objects.filter(contest_object=self.object)
|
||||||
|
@ -542,9 +549,10 @@ class ContestStats(TitleMixin, ContestMixin, DetailView):
|
||||||
queryset.values('problem__code', 'result').annotate(count=Count('result'))
|
queryset.values('problem__code', 'result').annotate(count=Count('result'))
|
||||||
.values_list('problem__code', 'result', 'count'),
|
.values_list('problem__code', 'result', 'count'),
|
||||||
)
|
)
|
||||||
labels, codes = zip(
|
labels, codes = [], []
|
||||||
*self.object.contest_problems.order_by('order').values_list('problem__name', 'problem__code'),
|
contest_problems = self.object.contest_problems.order_by('order').values_list('problem__name', 'problem__code')
|
||||||
)
|
if contest_problems:
|
||||||
|
labels, codes = zip(*contest_problems)
|
||||||
num_problems = len(labels)
|
num_problems = len(labels)
|
||||||
status_counts = [[] for i in range(num_problems)]
|
status_counts = [[] for i in range(num_problems)]
|
||||||
for problem_code, result, count in status_count_queryset:
|
for problem_code, result, count in status_count_queryset:
|
||||||
|
@ -556,6 +564,21 @@ class ContestStats(TitleMixin, ContestMixin, DetailView):
|
||||||
for category in _get_result_data(defaultdict(int, status_counts[i]))['categories']:
|
for category in _get_result_data(defaultdict(int, status_counts[i]))['categories']:
|
||||||
result_data[category['code']][i] = category['count']
|
result_data[category['code']][i] = category['count']
|
||||||
|
|
||||||
|
problem_points = [[] for _ in range(num_problems)]
|
||||||
|
point_count_queryset = list(queryset.values('problem__code', 'contest__points', 'contest__problem__points')
|
||||||
|
.annotate(count=Count('contest__points'))
|
||||||
|
.order_by('problem__code', 'contest__points')
|
||||||
|
.values_list('problem__code', 'contest__points', 'contest__problem__points', 'count'))
|
||||||
|
counter = [[0 for _ in range(self.POINT_BIN + 1)] for _ in range(num_problems)]
|
||||||
|
for problem_code, point, max_point, count in point_count_queryset:
|
||||||
|
if (point == None) or (problem_code not in codes): continue
|
||||||
|
problem_idx = codes.index(problem_code)
|
||||||
|
bin_idx = math.floor(point * self.POINT_BIN / max_point)
|
||||||
|
counter[problem_idx][bin_idx] += count
|
||||||
|
for i in range(num_problems):
|
||||||
|
problem_points[i] = [(j * 100 / self.POINT_BIN, counter[i][j])
|
||||||
|
for j in range(len(counter[i]))]
|
||||||
|
|
||||||
stats = {
|
stats = {
|
||||||
'problem_status_count': {
|
'problem_status_count': {
|
||||||
'labels': labels,
|
'labels': labels,
|
||||||
|
@ -572,6 +595,9 @@ class ContestStats(TitleMixin, ContestMixin, DetailView):
|
||||||
queryset.values('contest__problem__order', 'problem__name').annotate(ac_rate=ac_rate)
|
queryset.values('contest__problem__order', 'problem__name').annotate(ac_rate=ac_rate)
|
||||||
.order_by('contest__problem__order').values_list('problem__name', 'ac_rate'),
|
.order_by('contest__problem__order').values_list('problem__name', 'ac_rate'),
|
||||||
),
|
),
|
||||||
|
'problem_point': [get_histogram(problem_points[i])
|
||||||
|
for i in range(num_problems)
|
||||||
|
],
|
||||||
'language_count': get_pie_chart(
|
'language_count': get_pie_chart(
|
||||||
queryset.values('language__name').annotate(count=Count('language__name'))
|
queryset.values('language__name').annotate(count=Count('language__name'))
|
||||||
.filter(count__gt=0).order_by('-count').values_list('language__name', 'count'),
|
.filter(count__gt=0).order_by('-count').values_list('language__name', 'count'),
|
||||||
|
@ -583,13 +609,13 @@ class ContestStats(TitleMixin, ContestMixin, DetailView):
|
||||||
}
|
}
|
||||||
|
|
||||||
context['stats'] = mark_safe(json.dumps(stats))
|
context['stats'] = mark_safe(json.dumps(stats))
|
||||||
|
context['problems'] = labels
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
ContestRankingProfile = namedtuple(
|
ContestRankingProfile = namedtuple(
|
||||||
'ContestRankingProfile',
|
'ContestRankingProfile',
|
||||||
'id user css_class username points cumtime organization participation '
|
'id user css_class username points cumtime tiebreaker organization participation '
|
||||||
'participation_rating problem_cells result_cell',
|
'participation_rating problem_cells result_cell',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -605,6 +631,7 @@ def make_contest_ranking_profile(contest, participation, contest_problems):
|
||||||
username=user.username,
|
username=user.username,
|
||||||
points=participation.score,
|
points=participation.score,
|
||||||
cumtime=participation.cumtime,
|
cumtime=participation.cumtime,
|
||||||
|
tiebreaker=participation.tiebreaker,
|
||||||
organization=user.organization,
|
organization=user.organization,
|
||||||
participation_rating=participation.rating.rating if hasattr(participation, 'rating') else None,
|
participation_rating=participation.rating.rating if hasattr(participation, 'rating') else None,
|
||||||
problem_cells=[contest.format.display_user_problem(participation, contest_problem)
|
problem_cells=[contest.format.display_user_problem(participation, contest_problem)
|
||||||
|
@ -619,22 +646,20 @@ def base_contest_ranking_list(contest, problems, queryset):
|
||||||
queryset.select_related('user__user', 'rating').defer('user__about', 'user__organizations__about')]
|
queryset.select_related('user__user', 'rating').defer('user__about', 'user__organizations__about')]
|
||||||
|
|
||||||
|
|
||||||
def contest_ranking_list(contest, problems):
|
def contest_ranking_list(contest, problems, queryset=None):
|
||||||
return base_contest_ranking_list(contest, problems, contest.users.filter(virtual=0, user__is_unlisted=False)
|
if not queryset:
|
||||||
|
queryset = contest.users.filter(virtual=0)
|
||||||
|
return base_contest_ranking_list(contest, problems, queryset
|
||||||
.prefetch_related('user__organizations')
|
.prefetch_related('user__organizations')
|
||||||
.extra(select={'round_score': 'round(score, 6)'})
|
.extra(select={'round_score': 'round(score, 6)'})
|
||||||
.order_by('is_disqualified', '-round_score', 'cumtime'))
|
.order_by('is_disqualified', '-round_score', 'cumtime', 'tiebreaker'))
|
||||||
|
|
||||||
|
|
||||||
def get_contest_ranking_list(request, contest, participation=None, ranking_list=contest_ranking_list,
|
def get_contest_ranking_list(request, contest, participation=None, ranking_list=contest_ranking_list,
|
||||||
show_current_virtual=True, ranker=ranker):
|
show_current_virtual=False, ranker=ranker):
|
||||||
problems = list(contest.contest_problems.select_related('problem').defer('problem__description').order_by('order'))
|
problems = list(contest.contest_problems.select_related('problem').defer('problem__description').order_by('order'))
|
||||||
|
|
||||||
if contest.hide_scoreboard and contest.is_in_contest(request.user):
|
users = ranker(ranking_list(contest, problems), key=attrgetter('points', 'cumtime', 'tiebreaker'))
|
||||||
return ([(_('???'), make_contest_ranking_profile(contest, request.profile.current_contest, problems))],
|
|
||||||
problems)
|
|
||||||
|
|
||||||
users = ranker(ranking_list(contest, problems), key=attrgetter('points', 'cumtime'))
|
|
||||||
|
|
||||||
if show_current_virtual:
|
if show_current_virtual:
|
||||||
if participation is None and request.user.is_authenticated:
|
if participation is None and request.user.is_authenticated:
|
||||||
|
@ -651,15 +676,24 @@ def contest_ranking_ajax(request, contest, participation=None):
|
||||||
if not exists:
|
if not exists:
|
||||||
return HttpResponseBadRequest('Invalid contest', content_type='text/plain')
|
return HttpResponseBadRequest('Invalid contest', content_type='text/plain')
|
||||||
|
|
||||||
if not contest.can_see_scoreboard(request.user):
|
if not contest.can_see_full_scoreboard(request.user):
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
|
||||||
users, problems = get_contest_ranking_list(request, contest, participation)
|
queryset = contest.users.filter(virtual__gte=0)
|
||||||
|
if request.GET.get('friend') == 'true' and request.profile:
|
||||||
|
friends = list(request.profile.get_friends())
|
||||||
|
queryset = queryset.filter(user__user__username__in=friends)
|
||||||
|
if request.GET.get('virtual') != 'true':
|
||||||
|
queryset = queryset.filter(virtual=0)
|
||||||
|
|
||||||
|
users, problems = get_contest_ranking_list(request, contest, participation,
|
||||||
|
ranking_list=partial(contest_ranking_list, queryset=queryset))
|
||||||
return render(request, 'contest/ranking-table.html', {
|
return render(request, 'contest/ranking-table.html', {
|
||||||
'users': users,
|
'users': users,
|
||||||
'problems': problems,
|
'problems': problems,
|
||||||
'contest': contest,
|
'contest': contest,
|
||||||
'has_rating': contest.ratings.exists(),
|
'has_rating': contest.ratings.exists(),
|
||||||
|
'can_edit': contest.is_editable_by(request.user)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -679,13 +713,12 @@ class ContestRankingBase(ContestMixin, TitleMixin, DetailView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super().get_context_data(**kwargs)
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
if not self.object.can_see_scoreboard(self.request.user):
|
if not self.object.can_see_own_scoreboard(self.request.user):
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
|
||||||
users, problems = self.get_ranking_list()
|
users, problems = self.get_ranking_list()
|
||||||
context['users'] = users
|
context['users'] = users
|
||||||
context['problems'] = problems
|
context['problems'] = problems
|
||||||
context['last_msg'] = event.last()
|
|
||||||
context['tab'] = self.tab
|
context['tab'] = self.tab
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
@ -697,6 +730,14 @@ class ContestRanking(ContestRankingBase):
|
||||||
return _('%s Rankings') % self.object.name
|
return _('%s Rankings') % self.object.name
|
||||||
|
|
||||||
def get_ranking_list(self):
|
def get_ranking_list(self):
|
||||||
|
if not self.object.can_see_full_scoreboard(self.request.user):
|
||||||
|
queryset = self.object.users.filter(user=self.request.profile, virtual=ContestParticipation.LIVE)
|
||||||
|
return get_contest_ranking_list(
|
||||||
|
self.request, self.object,
|
||||||
|
ranking_list=partial(base_contest_ranking_list, queryset=queryset),
|
||||||
|
ranker=lambda users, key: ((_('???'), user) for user in users),
|
||||||
|
)
|
||||||
|
|
||||||
return get_contest_ranking_list(self.request, self.object)
|
return get_contest_ranking_list(self.request, self.object)
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
|
@ -714,6 +755,9 @@ class ContestParticipationList(LoginRequiredMixin, ContestRankingBase):
|
||||||
return _("%s's participation in %s") % (self.profile.username, self.object.name)
|
return _("%s's participation in %s") % (self.profile.username, self.object.name)
|
||||||
|
|
||||||
def get_ranking_list(self):
|
def get_ranking_list(self):
|
||||||
|
if not self.object.can_see_full_scoreboard(self.request.user) and self.profile != self.request.profile:
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
queryset = self.object.users.filter(user=self.profile, virtual__gte=0).order_by('-virtual')
|
queryset = self.object.users.filter(user=self.profile, virtual__gte=0).order_by('-virtual')
|
||||||
live_link = format_html('<a href="{2}#!{1}">{0}</a>', _('Live'), self.profile.username,
|
live_link = format_html('<a href="{2}#!{1}">{0}</a>', _('Live'), self.profile.username,
|
||||||
reverse('contest_ranking', args=[self.object.key]))
|
reverse('contest_ranking', args=[self.object.key]))
|
||||||
|
@ -728,6 +772,7 @@ class ContestParticipationList(LoginRequiredMixin, ContestRankingBase):
|
||||||
context['has_rating'] = False
|
context['has_rating'] = False
|
||||||
context['now'] = timezone.now()
|
context['now'] = timezone.now()
|
||||||
context['rank_header'] = _('Participation')
|
context['rank_header'] = _('Participation')
|
||||||
|
context['participation_tab'] = True
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get(self, request, *args, **kwargs):
|
def get(self, request, *args, **kwargs):
|
||||||
|
@ -762,7 +807,7 @@ class ContestMossMixin(ContestMixin, PermissionRequiredMixin):
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
contest = super().get_object(queryset)
|
contest = super().get_object(queryset)
|
||||||
if settings.MOSS_API_KEY is None:
|
if settings.MOSS_API_KEY is None or not contest.is_editable_by(self.request.user):
|
||||||
raise Http404()
|
raise Http404()
|
||||||
if not contest.is_editable_by(self.request.user):
|
if not contest.is_editable_by(self.request.user):
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
@ -824,3 +869,87 @@ class ContestTagDetail(TitleMixin, ContestTagDetailAjax):
|
||||||
|
|
||||||
def get_title(self):
|
def get_title(self):
|
||||||
return _('Contest tag: %s') % self.object.name
|
return _('Contest tag: %s') % self.object.name
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemClarificationForm(forms.Form):
|
||||||
|
body = forms.CharField(widget=HeavyPreviewPageDownWidget(preview=reverse_lazy('comment_preview'),
|
||||||
|
preview_timeout=1000, hide_preview_button=True))
|
||||||
|
|
||||||
|
def __init__(self, request, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
super(ProblemClarificationForm, self).__init__(*args, **kwargs)
|
||||||
|
self.fields['body'].widget.attrs.update({'placeholder': _('Issue description')})
|
||||||
|
|
||||||
|
|
||||||
|
class NewContestClarificationView(ContestMixin, TitleMixin, SingleObjectFormView):
|
||||||
|
form_class = ProblemClarificationForm
|
||||||
|
template_name = 'contest/clarification.html'
|
||||||
|
|
||||||
|
def get_form_kwargs(self):
|
||||||
|
kwargs = super(NewContestClarificationView, self).get_form_kwargs()
|
||||||
|
kwargs['request'] = self.request
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
def is_accessible(self):
|
||||||
|
if not self.request.user.is_authenticated:
|
||||||
|
return False
|
||||||
|
if not self.request.in_contest:
|
||||||
|
return False
|
||||||
|
if not self.request.participation.contest == self.get_object():
|
||||||
|
return False
|
||||||
|
return self.request.user.is_superuser or \
|
||||||
|
self.request.profile in self.request.participation.contest.authors.all() or \
|
||||||
|
self.request.profile in self.request.participation.contest.curators.all()
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
if not self.is_accessible():
|
||||||
|
raise Http404()
|
||||||
|
return super().get(self, request, *args, **kwargs)
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
problem_code = self.request.POST['problem']
|
||||||
|
description = form.cleaned_data['body']
|
||||||
|
|
||||||
|
clarification = ProblemClarification(description=description)
|
||||||
|
clarification.problem = Problem.objects.get(code=problem_code)
|
||||||
|
clarification.save()
|
||||||
|
|
||||||
|
link = reverse('home')
|
||||||
|
return HttpResponseRedirect(link)
|
||||||
|
|
||||||
|
def get_title(self):
|
||||||
|
return "New clarification for %s" % self.object.name
|
||||||
|
|
||||||
|
def get_content_title(self):
|
||||||
|
return mark_safe(escape(_('New clarification for %s')) %
|
||||||
|
format_html('<a href="{0}">{1}</a>', reverse('problem_detail', args=[self.object.key]),
|
||||||
|
self.object.name))
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super(NewContestClarificationView, self).get_context_data(**kwargs)
|
||||||
|
context['problems'] = ContestProblem.objects.filter(contest=self.object)\
|
||||||
|
.order_by('order')
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ContestClarificationAjax(ContestMixin, DetailView):
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
if not self.object.is_accessible_by(request.user):
|
||||||
|
raise Http404()
|
||||||
|
|
||||||
|
polling_time = 1 # minute
|
||||||
|
last_one_minute = last_five_minutes = timezone.now()-timezone.timedelta(minutes=polling_time)
|
||||||
|
|
||||||
|
queryset = list(ProblemClarification.objects.filter(
|
||||||
|
problem__in=self.object.problems.all(),
|
||||||
|
date__gte=last_one_minute
|
||||||
|
).values('problem', 'problem__name', 'description'))
|
||||||
|
|
||||||
|
problems = list(ContestProblem.objects.filter(contest=self.object)\
|
||||||
|
.order_by('order').values('problem'))
|
||||||
|
problems = [i['problem'] for i in problems]
|
||||||
|
for cla in queryset:
|
||||||
|
cla['order'] = self.object.get_label_for_problem(problems.index(cla['problem']))
|
||||||
|
|
||||||
|
return JsonResponse(queryset, safe=False, json_dumps_params={'ensure_ascii': False})
|
||||||
|
|
|
@ -11,6 +11,7 @@ from django.forms import Form, modelformset_factory
|
||||||
from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
|
from django.http import Http404, HttpResponsePermanentRedirect, HttpResponseRedirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext as _, gettext_lazy, ungettext
|
from django.utils.translation import gettext as _, gettext_lazy, ungettext
|
||||||
from django.views.generic import DetailView, FormView, ListView, UpdateView, View
|
from django.views.generic import DetailView, FormView, ListView, UpdateView, View
|
||||||
from django.views.generic.detail import SingleObjectMixin, SingleObjectTemplateResponseMixin
|
from django.views.generic.detail import SingleObjectMixin, SingleObjectTemplateResponseMixin
|
||||||
|
@ -114,6 +115,7 @@ class OrganizationHome(OrganizationDetailView):
|
||||||
Comment.objects.filter(page__in=['b:%d' % post.id for post in context['posts']], hidden=False)
|
Comment.objects.filter(page__in=['b:%d' % post.id for post in context['posts']], hidden=False)
|
||||||
.values_list('page').annotate(count=Count('page')).order_by()
|
.values_list('page').annotate(count=Count('page')).order_by()
|
||||||
}
|
}
|
||||||
|
context['pending_count'] = OrganizationRequest.objects.filter(state='P', organization=self.object).count()
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -230,7 +232,7 @@ class OrganizationRequestDetail(LoginRequiredMixin, TitleMixin, DetailView):
|
||||||
OrganizationRequestFormSet = modelformset_factory(OrganizationRequest, extra=0, fields=('state',), can_delete=True)
|
OrganizationRequestFormSet = modelformset_factory(OrganizationRequest, extra=0, fields=('state',), can_delete=True)
|
||||||
|
|
||||||
|
|
||||||
class OrganizationRequestBaseView(LoginRequiredMixin, SingleObjectTemplateResponseMixin, SingleObjectMixin, View):
|
class OrganizationRequestBaseView(TitleMixin, LoginRequiredMixin, SingleObjectTemplateResponseMixin, SingleObjectMixin, View):
|
||||||
model = Organization
|
model = Organization
|
||||||
slug_field = 'key'
|
slug_field = 'key'
|
||||||
slug_url_kwarg = 'key'
|
slug_url_kwarg = 'key'
|
||||||
|
@ -243,6 +245,10 @@ class OrganizationRequestBaseView(LoginRequiredMixin, SingleObjectTemplateRespon
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
return organization
|
return organization
|
||||||
|
|
||||||
|
def get_content_title(self):
|
||||||
|
href = reverse('organization_home', args=[self.object.id, self.object.slug])
|
||||||
|
return mark_safe(f'Manage join requests for <a href="{href}">{self.object.name}</a>')
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(OrganizationRequestBaseView, self).get_context_data(**kwargs)
|
context = super(OrganizationRequestBaseView, self).get_context_data(**kwargs)
|
||||||
context['title'] = _('Managing join requests for %s') % self.object.name
|
context['title'] = _('Managing join requests for %s') % self.object.name
|
||||||
|
|
|
@ -21,14 +21,14 @@ from django.utils.functional import cached_property
|
||||||
from django.utils.html import escape, format_html
|
from django.utils.html import escape, format_html
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext as _, gettext_lazy
|
from django.utils.translation import gettext as _, gettext_lazy
|
||||||
from django.views.generic import ListView, View
|
from django.views.generic import DetailView, ListView, View
|
||||||
from django.views.generic.base import TemplateResponseMixin
|
from django.views.generic.base import TemplateResponseMixin
|
||||||
from django.views.generic.detail import SingleObjectMixin
|
from django.views.generic.detail import SingleObjectMixin
|
||||||
|
|
||||||
from judge.comments import CommentedDetailView
|
from judge.comments import CommentedDetailView
|
||||||
from judge.forms import ProblemCloneForm, ProblemSubmitForm
|
from judge.forms import ProblemCloneForm, ProblemSubmitForm
|
||||||
from judge.models import ContestProblem, ContestSubmission, Judge, Language, Problem, ProblemGroup, \
|
from judge.models import ContestProblem, ContestSubmission, Judge, Language, Problem, ProblemClarification, \
|
||||||
ProblemTranslation, ProblemType, RuntimeVersion, Solution, Submission, SubmissionSource, \
|
ProblemGroup, ProblemTranslation, ProblemType, RuntimeVersion, Solution, Submission, SubmissionSource, \
|
||||||
TranslatedProblemForeignKeyQuerySet, Organization
|
TranslatedProblemForeignKeyQuerySet, Organization
|
||||||
from judge.pdf_problems import DefaultPdfMaker, HAS_PDF
|
from judge.pdf_problems import DefaultPdfMaker, HAS_PDF
|
||||||
from judge.utils.diggpaginator import DiggPaginator
|
from judge.utils.diggpaginator import DiggPaginator
|
||||||
|
@ -154,13 +154,10 @@ class ProblemRaw(ProblemMixin, TitleMixin, TemplateResponseMixin, SingleObjectMi
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
class ProblemDetail(ProblemMixin, SolvedProblemMixin, CommentedDetailView):
|
class ProblemDetail(ProblemMixin, SolvedProblemMixin, DetailView):
|
||||||
context_object_name = 'problem'
|
context_object_name = 'problem'
|
||||||
template_name = 'problem/problem.html'
|
template_name = 'problem/problem.html'
|
||||||
|
|
||||||
def get_comment_page(self):
|
|
||||||
return 'p:%s' % self.object.code
|
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(ProblemDetail, self).get_context_data(**kwargs)
|
context = super(ProblemDetail, self).get_context_data(**kwargs)
|
||||||
user = self.request.user
|
user = self.request.user
|
||||||
|
@ -170,6 +167,7 @@ class ProblemDetail(ProblemMixin, SolvedProblemMixin, CommentedDetailView):
|
||||||
contest_problem = (None if not authed or user.profile.current_contest is None else
|
contest_problem = (None if not authed or user.profile.current_contest is None else
|
||||||
get_contest_problem(self.object, user.profile))
|
get_contest_problem(self.object, user.profile))
|
||||||
context['contest_problem'] = contest_problem
|
context['contest_problem'] = contest_problem
|
||||||
|
|
||||||
if contest_problem:
|
if contest_problem:
|
||||||
clarifications = self.object.clarifications
|
clarifications = self.object.clarifications
|
||||||
context['has_clarifications'] = clarifications.count() > 0
|
context['has_clarifications'] = clarifications.count() > 0
|
||||||
|
@ -220,6 +218,21 @@ class ProblemDetail(ProblemMixin, SolvedProblemMixin, CommentedDetailView):
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemComments(ProblemMixin, TitleMixin, CommentedDetailView):
|
||||||
|
context_object_name = 'problem'
|
||||||
|
template_name = 'problem/comments.html'
|
||||||
|
|
||||||
|
def get_title(self):
|
||||||
|
return _('Disscuss {0}').format(self.object.name)
|
||||||
|
|
||||||
|
def get_content_title(self):
|
||||||
|
return format_html(_(u'Discuss <a href="{1}">{0}</a>'), self.object.name,
|
||||||
|
reverse('problem_detail', args=[self.object.code]))
|
||||||
|
|
||||||
|
def get_comment_page(self):
|
||||||
|
return 'p:%s' % self.object.code
|
||||||
|
|
||||||
|
|
||||||
class LatexError(Exception):
|
class LatexError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -256,7 +269,6 @@ class ProblemPdfView(ProblemMixin, SingleObjectMixin, View):
|
||||||
'math_engine': maker.math_engine,
|
'math_engine': maker.math_engine,
|
||||||
}).replace('"//', '"https://').replace("'//", "'https://")
|
}).replace('"//', '"https://').replace("'//", "'https://")
|
||||||
maker.title = problem_name
|
maker.title = problem_name
|
||||||
|
|
||||||
assets = ['style.css', 'pygment-github.css']
|
assets = ['style.css', 'pygment-github.css']
|
||||||
if maker.math_engine == 'jax':
|
if maker.math_engine == 'jax':
|
||||||
assets.append('mathjax_config.js')
|
assets.append('mathjax_config.js')
|
||||||
|
@ -267,7 +279,6 @@ class ProblemPdfView(ProblemMixin, SingleObjectMixin, View):
|
||||||
self.logger.error('Failed to render PDF for %s', problem.code)
|
self.logger.error('Failed to render PDF for %s', problem.code)
|
||||||
return HttpResponse(maker.log, status=500, content_type='text/plain')
|
return HttpResponse(maker.log, status=500, content_type='text/plain')
|
||||||
shutil.move(maker.pdffile, cache)
|
shutil.move(maker.pdffile, cache)
|
||||||
|
|
||||||
response = HttpResponse()
|
response = HttpResponse()
|
||||||
if hasattr(settings, 'DMOJ_PDF_PROBLEM_INTERNAL') and \
|
if hasattr(settings, 'DMOJ_PDF_PROBLEM_INTERNAL') and \
|
||||||
request.META.get('SERVER_SOFTWARE', '').startswith('nginx/'):
|
request.META.get('SERVER_SOFTWARE', '').startswith('nginx/'):
|
||||||
|
@ -438,7 +449,17 @@ class ProblemList(QueryStringSortMixin, TitleMixin, SolvedProblemMixin, ListView
|
||||||
else:
|
else:
|
||||||
context['hot_problems'] = None
|
context['hot_problems'] = None
|
||||||
context['point_start'], context['point_end'], context['point_values'] = 0, 0, {}
|
context['point_start'], context['point_end'], context['point_values'] = 0, 0, {}
|
||||||
context['hide_contest_scoreboard'] = self.contest.hide_scoreboard
|
context['hide_contest_scoreboard'] = self.contest.scoreboard_visibility in \
|
||||||
|
(self.contest.SCOREBOARD_AFTER_CONTEST, self.contest.SCOREBOARD_AFTER_PARTICIPATION)
|
||||||
|
context['has_clarifications'] = False
|
||||||
|
if self.request.user.is_authenticated:
|
||||||
|
participation = self.request.profile.current_contest
|
||||||
|
if participation:
|
||||||
|
clarifications = ProblemClarification.objects.filter(problem__in=participation.contest.problems.all())
|
||||||
|
context['has_clarifications'] = clarifications.count() > 0
|
||||||
|
context['clarifications'] = clarifications.order_by('-date')
|
||||||
|
if participation.contest.is_editable_by(self.request.user):
|
||||||
|
context['can_edit_contest'] = True
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def get_noui_slider_points(self):
|
def get_noui_slider_points(self):
|
||||||
|
|
|
@ -2,14 +2,24 @@ import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
|
import shutil
|
||||||
|
from tempfile import gettempdir
|
||||||
from zipfile import BadZipfile, ZipFile
|
from zipfile import BadZipfile, ZipFile
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import HttpResponse, HttpRequest
|
||||||
|
from django.shortcuts import render
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.generic import View
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
|
from django.core.files import File
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.forms import BaseModelFormSet, HiddenInput, ModelForm, NumberInput, Select, formset_factory, FileInput
|
from django.forms import BaseModelFormSet, HiddenInput, ModelForm, NumberInput, Select, formset_factory, FileInput
|
||||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.html import escape, format_html
|
from django.utils.html import escape, format_html
|
||||||
|
@ -22,6 +32,7 @@ from judge.models import Problem, ProblemData, ProblemTestCase, Submission, prob
|
||||||
from judge.utils.problem_data import ProblemDataCompiler
|
from judge.utils.problem_data import ProblemDataCompiler
|
||||||
from judge.utils.unicode import utf8text
|
from judge.utils.unicode import utf8text
|
||||||
from judge.utils.views import TitleMixin
|
from judge.utils.views import TitleMixin
|
||||||
|
from judge.utils.fine_uploader import combine_chunks, save_upload, handle_upload, FineUploadFileInput, FineUploadForm
|
||||||
from judge.views.problem import ProblemMixin
|
from judge.views.problem import ProblemMixin
|
||||||
|
|
||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
|
@ -52,6 +63,7 @@ class ProblemDataForm(ModelForm):
|
||||||
model = ProblemData
|
model = ProblemData
|
||||||
fields = ['zipfile', 'checker', 'checker_args', 'custom_checker', 'custom_validator']
|
fields = ['zipfile', 'checker', 'checker_args', 'custom_checker', 'custom_validator']
|
||||||
widgets = {
|
widgets = {
|
||||||
|
'zipfile': FineUploadFileInput,
|
||||||
'checker_args': HiddenInput,
|
'checker_args': HiddenInput,
|
||||||
'generator': HiddenInput,
|
'generator': HiddenInput,
|
||||||
'output_limit': HiddenInput,
|
'output_limit': HiddenInput,
|
||||||
|
@ -76,6 +88,7 @@ class ProblemCaseForm(ModelForm):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ProblemCaseFormSet(formset_factory(ProblemCaseForm, formset=BaseModelFormSet, extra=1, max_num=1,
|
class ProblemCaseFormSet(formset_factory(ProblemCaseForm, formset=BaseModelFormSet, extra=1, max_num=1,
|
||||||
can_delete=True)):
|
can_delete=True)):
|
||||||
model = ProblemTestCase
|
model = ProblemTestCase
|
||||||
|
@ -242,3 +255,39 @@ def problem_init_view(request, problem):
|
||||||
format_html('<a href="{1}">{0}</a>', problem.name,
|
format_html('<a href="{1}">{0}</a>', problem.name,
|
||||||
reverse('problem_detail', args=[problem.code])))),
|
reverse('problem_detail', args=[problem.code])))),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ProblemZipUploadView(ProblemManagerMixin, View):
|
||||||
|
def dispatch(self, *args, **kwargs):
|
||||||
|
return super(ProblemZipUploadView, self).dispatch(*args, **kwargs)
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.object = problem = self.get_object()
|
||||||
|
problem_data = get_object_or_404(ProblemData, problem=self.object)
|
||||||
|
form = FineUploadForm(request.POST, request.FILES)
|
||||||
|
|
||||||
|
if form.is_valid():
|
||||||
|
fileuid = form.cleaned_data['qquuid']
|
||||||
|
filename = form.cleaned_data['qqfilename']
|
||||||
|
dest = os.path.join(gettempdir(), fileuid)
|
||||||
|
|
||||||
|
def post_upload():
|
||||||
|
zip_dest = os.path.join(dest, filename)
|
||||||
|
try:
|
||||||
|
ZipFile(zip_dest).namelist() # check if this file is valid
|
||||||
|
with open(zip_dest, 'rb') as f:
|
||||||
|
problem_data.zipfile.delete()
|
||||||
|
problem_data.zipfile.save(filename, File(f))
|
||||||
|
f.close()
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(e)
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(dest)
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle_upload(request.FILES['qqfile'], form.cleaned_data, dest, post_upload=post_upload)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'error': str(e)})
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
else:
|
||||||
|
return HttpResponse(status_code=400)
|
|
@ -40,6 +40,16 @@ class CustomRegistrationForm(RegistrationForm):
|
||||||
if ReCaptchaField is not None:
|
if ReCaptchaField is not None:
|
||||||
captcha = ReCaptchaField(widget=ReCaptchaWidget())
|
captcha = ReCaptchaField(widget=ReCaptchaWidget())
|
||||||
|
|
||||||
|
def clean_organizations(self):
|
||||||
|
organizations = self.cleaned_data.get('organizations') or []
|
||||||
|
max_orgs = settings.DMOJ_USER_MAX_ORGANIZATION_COUNT
|
||||||
|
|
||||||
|
if sum(org.is_open for org in organizations) > max_orgs:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
_('You may not be part of more than {count} public organizations.').format(count=max_orgs))
|
||||||
|
|
||||||
|
return self.cleaned_data['organizations']
|
||||||
|
|
||||||
def clean_email(self):
|
def clean_email(self):
|
||||||
if User.objects.filter(email=self.cleaned_data['email']).exists():
|
if User.objects.filter(email=self.cleaned_data['email']).exists():
|
||||||
raise forms.ValidationError(gettext('The email address "%s" is already taken. Only one registration '
|
raise forms.ValidationError(gettext('The email address "%s" is already taken. Only one registration '
|
||||||
|
@ -71,7 +81,7 @@ class RegistrationView(OldRegistrationView):
|
||||||
def register(self, form):
|
def register(self, form):
|
||||||
user = super(RegistrationView, self).register(form)
|
user = super(RegistrationView, self).register(form)
|
||||||
profile, _ = Profile.objects.get_or_create(user=user, defaults={
|
profile, _ = Profile.objects.get_or_create(user=user, defaults={
|
||||||
'language': Language.get_python3(),
|
'language': Language.get_default_language(),
|
||||||
})
|
})
|
||||||
|
|
||||||
cleaned_data = form.cleaned_data
|
cleaned_data = form.cleaned_data
|
||||||
|
|
|
@ -4,6 +4,8 @@ from django.shortcuts import get_object_or_404
|
||||||
from django.utils.encoding import smart_text
|
from django.utils.encoding import smart_text
|
||||||
from django.views.generic.list import BaseListView
|
from django.views.generic.list import BaseListView
|
||||||
|
|
||||||
|
from chat_box.utils import encrypt_url
|
||||||
|
|
||||||
from judge.jinja2.gravatar import gravatar
|
from judge.jinja2.gravatar import gravatar
|
||||||
from judge.models import Comment, Contest, Organization, Problem, Profile
|
from judge.models import Comment, Contest, Organization, Problem, Profile
|
||||||
|
|
||||||
|
@ -54,29 +56,14 @@ class OrganizationSelect2View(Select2View):
|
||||||
|
|
||||||
class ProblemSelect2View(Select2View):
|
class ProblemSelect2View(Select2View):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = Problem.objects.filter(Q(code__icontains=self.term) | Q(name__icontains=self.term))
|
return Problem.get_visible_problems(self.request.user) \
|
||||||
if not self.request.user.has_perm('judge.see_private_problem'):
|
.filter(Q(code__icontains=self.term) | Q(name__icontains=self.term)).distinct()
|
||||||
filter = Q(is_public=True)
|
|
||||||
if self.request.user.is_authenticated:
|
|
||||||
filter |= Q(authors=self.request.profile) | Q(curators=self.request.profile)
|
|
||||||
queryset = queryset.filter(filter).distinct()
|
|
||||||
return queryset.distinct()
|
|
||||||
|
|
||||||
|
|
||||||
class ContestSelect2View(Select2View):
|
class ContestSelect2View(Select2View):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = Contest.objects.filter(Q(key__icontains=self.term) | Q(name__icontains=self.term))
|
return Contest.get_visible_contests(self.request.user) \
|
||||||
if not self.request.user.has_perm('judge.see_private_contest'):
|
.filter(Q(key__icontains=self.term) | Q(name__icontains=self.term))
|
||||||
queryset = queryset.filter(is_visible=True)
|
|
||||||
if not self.request.user.has_perm('judge.edit_all_contest'):
|
|
||||||
q = Q(is_private=False, is_organization_private=False)
|
|
||||||
if self.request.user.is_authenticated:
|
|
||||||
q |= Q(is_organization_private=True,
|
|
||||||
organizations__in=self.request.profile.organizations.all())
|
|
||||||
q |= Q(is_private=True, private_contestants=self.request.profile)
|
|
||||||
q |= Q(view_contest_scoreboard=self.request.profile)
|
|
||||||
queryset = queryset.filter(q)
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
|
|
||||||
class CommentSelect2View(Select2View):
|
class CommentSelect2View(Select2View):
|
||||||
|
@ -119,8 +106,7 @@ class UserSearchSelect2View(BaseListView):
|
||||||
class ContestUserSearchSelect2View(UserSearchSelect2View):
|
class ContestUserSearchSelect2View(UserSearchSelect2View):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
contest = get_object_or_404(Contest, key=self.kwargs['contest'])
|
contest = get_object_or_404(Contest, key=self.kwargs['contest'])
|
||||||
if not contest.can_see_scoreboard(self.request.user) or \
|
if not contest.is_accessible_by(self.request.user) or not contest.can_see_full_scoreboard(self.request.user):
|
||||||
contest.hide_scoreboard and contest.is_in_contest(self.request.user):
|
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
|
||||||
return Profile.objects.filter(contest_history__contest=contest,
|
return Profile.objects.filter(contest_history__contest=contest,
|
||||||
|
@ -137,3 +123,35 @@ class AssigneeSelect2View(UserSearchSelect2View):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Profile.objects.filter(assigned_tickets__isnull=False,
|
return Profile.objects.filter(assigned_tickets__isnull=False,
|
||||||
user__username__icontains=self.term).distinct()
|
user__username__icontains=self.term).distinct()
|
||||||
|
|
||||||
|
|
||||||
|
class ChatUserSearchSelect2View(BaseListView):
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self): # TODO: add block
|
||||||
|
return _get_user_queryset(self.term)
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
self.request = request
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self.term = kwargs.get('term', request.GET.get('term', ''))
|
||||||
|
self.gravatar_size = request.GET.get('gravatar_size', 128)
|
||||||
|
self.gravatar_default = request.GET.get('gravatar_default', None)
|
||||||
|
|
||||||
|
self.object_list = self.get_queryset().values_list('pk', 'user__username', 'user__email', 'display_rank')
|
||||||
|
|
||||||
|
context = self.get_context_data()
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'results': [
|
||||||
|
{
|
||||||
|
'text': username,
|
||||||
|
'id': encrypt_url(request.profile.id, pk),
|
||||||
|
'gravatar_url': gravatar(email, self.gravatar_size, self.gravatar_default),
|
||||||
|
'display_rank': display_rank,
|
||||||
|
} for pk, username, email, display_rank in context['object_list']],
|
||||||
|
'more': context['page_obj'].has_next(),
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_name(self, obj):
|
||||||
|
return str(obj)
|
|
@ -43,7 +43,8 @@ from judge.utils.problems import get_result_data
|
||||||
from judge.utils.problems import user_authored_ids
|
from judge.utils.problems import user_authored_ids
|
||||||
from judge.utils.problems import user_completed_ids
|
from judge.utils.problems import user_completed_ids
|
||||||
from judge.utils.problems import user_editable_ids
|
from judge.utils.problems import user_editable_ids
|
||||||
from judge.utils.raw_sql import use_straight_join
|
from judge.utils.problem_data import get_problem_case
|
||||||
|
from judge.utils.raw_sql import join_sql_subquery, use_straight_join
|
||||||
from judge.utils.views import DiggPaginatorMixin
|
from judge.utils.views import DiggPaginatorMixin
|
||||||
from judge.utils.views import TitleMixin
|
from judge.utils.views import TitleMixin
|
||||||
|
|
||||||
|
@ -110,7 +111,7 @@ class SubmissionSource(SubmissionDetailBase):
|
||||||
submission = self.object
|
submission = self.object
|
||||||
context['raw_source'] = submission.source.source.rstrip('\n')
|
context['raw_source'] = submission.source.source.rstrip('\n')
|
||||||
context['highlighted_source'] = highlight_code(
|
context['highlighted_source'] = highlight_code(
|
||||||
submission.source.source, submission.language.pygments)
|
submission.source.source, submission.language.pygments, linenos=False)
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -137,60 +138,28 @@ def group_test_cases(cases):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def read_head_archive(archive, file):
|
def get_cases_data(submission):
|
||||||
with archive.open(file) as f:
|
|
||||||
s = f.read(settings.TESTCASE_VISIBLE_LENGTH + 3)
|
|
||||||
# add this so there are no characters left behind (ex, 'á' = 2 utf-8 chars)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
s.decode('utf-8')
|
|
||||||
break
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
s += f.read(1)
|
|
||||||
return s
|
|
||||||
|
|
||||||
|
|
||||||
def get_visible_content(data):
|
|
||||||
data = data or b''
|
|
||||||
data = data.replace(b'\r\n', b'\r').replace(b'\r', b'\n')
|
|
||||||
|
|
||||||
data = data.decode('utf-8')
|
|
||||||
|
|
||||||
if (len(data) > settings.TESTCASE_VISIBLE_LENGTH):
|
|
||||||
data = data[:settings.TESTCASE_VISIBLE_LENGTH]
|
|
||||||
data += '.' * 3
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def get_input_answer(case, archive):
|
|
||||||
result = {'input': '', 'answer': ''}
|
|
||||||
if (len(case.input_file)):
|
|
||||||
result['input'] = get_visible_content(read_head_archive(archive, case.input_file))
|
|
||||||
if (len(case.output_file)):
|
|
||||||
result['answer'] = get_visible_content(read_head_archive(archive, case.output_file))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_problem_data(submission):
|
|
||||||
archive_path = os.path.join(settings.DMOJ_PROBLEM_DATA_ROOT,
|
|
||||||
str(submission.problem.data_files.zipfile))
|
|
||||||
if not os.path.exists(archive_path):
|
|
||||||
raise Exception(
|
|
||||||
'archive file "%s" does not exist' % archive_path)
|
|
||||||
try:
|
|
||||||
archive = zipfile.ZipFile(archive_path, 'r')
|
|
||||||
except zipfile.BadZipfile:
|
|
||||||
raise Exception('bad archive: "%s"' % archive_path)
|
|
||||||
|
|
||||||
testcases = ProblemTestCase.objects.filter(dataset=submission.problem)\
|
testcases = ProblemTestCase.objects.filter(dataset=submission.problem)\
|
||||||
.order_by('order')
|
.order_by('order')
|
||||||
|
|
||||||
if (submission.is_pretested):
|
if (submission.is_pretested):
|
||||||
testcases = testcases.filter(is_pretest=True)
|
testcases = testcases.filter(is_pretest=True)
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for case in testcases:
|
||||||
|
if case.input_file: files.append(case.input_file)
|
||||||
|
if case.output_file: files.append(case.output_file)
|
||||||
|
case_data = get_problem_case(submission.problem, files)
|
||||||
|
|
||||||
problem_data = {}
|
problem_data = {}
|
||||||
for count, case in enumerate(testcases):
|
count = 0
|
||||||
problem_data[count + 1] = get_input_answer(case, archive)
|
for case in testcases:
|
||||||
|
if case.type != 'C': continue
|
||||||
|
count += 1
|
||||||
|
problem_data[count] = {
|
||||||
|
'input': case_data[case.input_file] if case.input_file else '',
|
||||||
|
'answer': case_data[case.output_file] if case.output_file else '',
|
||||||
|
}
|
||||||
|
|
||||||
return problem_data
|
return problem_data
|
||||||
|
|
||||||
|
@ -198,20 +167,36 @@ def get_problem_data(submission):
|
||||||
class SubmissionStatus(SubmissionDetailBase):
|
class SubmissionStatus(SubmissionDetailBase):
|
||||||
template_name = 'submission/status.html'
|
template_name = 'submission/status.html'
|
||||||
|
|
||||||
|
def access_testcases_in_contest(self):
|
||||||
|
contest = self.object.contest_or_none
|
||||||
|
if contest is None:
|
||||||
|
return False
|
||||||
|
if contest.problem.problem.is_editable_by(self.request.user):
|
||||||
|
return True
|
||||||
|
if contest.problem.contest.is_in_contest(self.request.user):
|
||||||
|
return False
|
||||||
|
if contest.participation.ended:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(SubmissionStatus, self).get_context_data(**kwargs)
|
context = super(SubmissionStatus, self).get_context_data(**kwargs)
|
||||||
submission = self.object
|
submission = self.object
|
||||||
context['last_msg'] = event.last()
|
context['last_msg'] = event.last()
|
||||||
context['batches'] = group_test_cases(submission.test_cases.all())
|
context['batches'] = group_test_cases(submission.test_cases.all())
|
||||||
context['time_limit'] = submission.problem.time_limit
|
context['time_limit'] = submission.problem.time_limit
|
||||||
|
context['can_see_testcases'] = False
|
||||||
|
|
||||||
contest = submission.contest_or_none
|
contest = submission.contest_or_none
|
||||||
prefix_length = 0
|
prefix_length = 0
|
||||||
|
can_see_testcases = self.access_testcases_in_contest()
|
||||||
|
|
||||||
if (contest is not None):
|
if (contest is not None):
|
||||||
prefix_length = contest.problem.output_prefix_override
|
prefix_length = contest.problem.output_prefix_override
|
||||||
if ((contest is None or prefix_length > 0) or self.request.user.is_superuser):
|
|
||||||
context['cases_data'] = get_problem_data(submission)
|
|
||||||
|
|
||||||
|
if contest is None or prefix_length > 0 or can_see_testcases:
|
||||||
|
context['cases_data'] = get_cases_data(submission)
|
||||||
|
context['can_see_testcases'] = True
|
||||||
try:
|
try:
|
||||||
lang_limit = submission.problem.language_limits.get(
|
lang_limit = submission.problem.language_limits.get(
|
||||||
language=submission.language)
|
language=submission.language)
|
||||||
|
@ -292,11 +277,9 @@ class SubmissionsListBase(DiggPaginatorMixin, TitleMixin, ListView):
|
||||||
queryset=ProblemTranslation.objects.filter(
|
queryset=ProblemTranslation.objects.filter(
|
||||||
language=self.request.LANGUAGE_CODE), to_attr='_trans'))
|
language=self.request.LANGUAGE_CODE), to_attr='_trans'))
|
||||||
if self.in_contest:
|
if self.in_contest:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(contest_object=self.contest)
|
||||||
contest__participation__contest_id=self.contest.id)
|
if not self.contest.can_see_full_scoreboard(self.request.user):
|
||||||
if self.contest.hide_scoreboard and self.contest.is_in_contest(self.request.user):
|
queryset = queryset.filter(user=self.request.profile)
|
||||||
queryset = queryset.filter(
|
|
||||||
contest__participation__user=self.request.profile)
|
|
||||||
else:
|
else:
|
||||||
queryset = queryset.select_related(
|
queryset = queryset.select_related(
|
||||||
'contest_object').defer('contest_object__description')
|
'contest_object').defer('contest_object__description')
|
||||||
|
@ -304,12 +287,18 @@ class SubmissionsListBase(DiggPaginatorMixin, TitleMixin, ListView):
|
||||||
# This is not technically correct since contest organizers *should* see these, but
|
# This is not technically correct since contest organizers *should* see these, but
|
||||||
# the join would be far too messy
|
# the join would be far too messy
|
||||||
if not self.request.user.has_perm('judge.see_private_contest'):
|
if not self.request.user.has_perm('judge.see_private_contest'):
|
||||||
queryset = queryset.exclude(
|
# Show submissions for any contest you can edit or visible scoreboard
|
||||||
contest_object_id__in=Contest.objects.filter(hide_scoreboard=True))
|
contest_queryset = Contest.objects.filter(Q(authors=self.request.profile) |
|
||||||
|
Q(curators=self.request.profile) |
|
||||||
|
Q(scoreboard_visibility=Contest.SCOREBOARD_VISIBLE) |
|
||||||
|
Q(end_time__lt=timezone.now())).distinct()
|
||||||
|
queryset = queryset.filter(Q(user=self.request.profile) |
|
||||||
|
Q(contest_object__in=contest_queryset) |
|
||||||
|
Q(contest_object__isnull=True))
|
||||||
|
|
||||||
if self.selected_languages:
|
if self.selected_languages:
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(
|
||||||
language_id__in=Language.objects.filter(key__in=self.selected_languages))
|
language__in=Language.objects.filter(key__in=self.selected_languages))
|
||||||
if self.selected_statuses:
|
if self.selected_statuses:
|
||||||
queryset = queryset.filter(result__in=self.selected_statuses)
|
queryset = queryset.filter(result__in=self.selected_statuses)
|
||||||
|
|
||||||
|
@ -318,14 +307,13 @@ class SubmissionsListBase(DiggPaginatorMixin, TitleMixin, ListView):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
queryset = self._get_queryset()
|
queryset = self._get_queryset()
|
||||||
if not self.in_contest:
|
if not self.in_contest:
|
||||||
if not self.request.user.has_perm('judge.see_private_problem'):
|
join_sql_subquery(
|
||||||
queryset = queryset.filter(problem__is_public=True)
|
queryset,
|
||||||
if not self.request.user.has_perm('judge.see_organization_problem'):
|
subquery=str(Problem.get_visible_problems(self.request.user).distinct().only('id').query),
|
||||||
filter = Q(problem__is_organization_private=False)
|
params=[],
|
||||||
if self.request.user.is_authenticated:
|
join_fields=[('problem_id', 'id')],
|
||||||
filter |= Q(
|
alias='visible_problems',
|
||||||
problem__organizations__in=self.request.profile.organizations.all())
|
)
|
||||||
queryset = queryset.filter(filter)
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def get_my_submissions_page(self):
|
def get_my_submissions_page(self):
|
||||||
|
@ -452,7 +440,7 @@ class ProblemSubmissionsBase(SubmissionsListBase):
|
||||||
reverse('problem_detail', args=[self.problem.code]))
|
reverse('problem_detail', args=[self.problem.code]))
|
||||||
|
|
||||||
def access_check_contest(self, request):
|
def access_check_contest(self, request):
|
||||||
if self.in_contest and not self.contest.can_see_scoreboard(request.user):
|
if self.in_contest and not self.contest.can_see_own_scoreboard(request.user):
|
||||||
raise Http404()
|
raise Http404()
|
||||||
|
|
||||||
def access_check(self, request):
|
def access_check(self, request):
|
||||||
|
|
|
@ -13,7 +13,8 @@ from django.db import transaction
|
||||||
from django.db.models import Count, Max, Min
|
from django.db.models import Count, Max, Min
|
||||||
from django.db.models.fields import DateField
|
from django.db.models.fields import DateField
|
||||||
from django.db.models.functions import Cast, ExtractYear
|
from django.db.models.functions import Cast, ExtractYear
|
||||||
from django.http import Http404, HttpResponseRedirect, JsonResponse
|
from django.forms import Form
|
||||||
|
from django.http import Http404, HttpResponseRedirect, JsonResponse, HttpResponseForbidden, HttpResponseBadRequest, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404, render
|
from django.shortcuts import get_object_or_404, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -21,18 +22,21 @@ from django.utils.formats import date_format
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext as _, gettext_lazy
|
from django.utils.translation import gettext as _, gettext_lazy
|
||||||
|
from django.views import View
|
||||||
from django.views.generic import DetailView, ListView, TemplateView
|
from django.views.generic import DetailView, ListView, TemplateView
|
||||||
|
from django.template.loader import render_to_string
|
||||||
from reversion import revisions
|
from reversion import revisions
|
||||||
|
|
||||||
from judge.forms import ProfileForm, newsletter_id
|
from judge.forms import ProfileForm, newsletter_id
|
||||||
from judge.models import Profile, Rating, Submission, Friend
|
from judge.models import Profile, Rating, Submission, Friend
|
||||||
from judge.performance_points import get_pp_breakdown
|
from judge.performance_points import get_pp_breakdown
|
||||||
from judge.ratings import rating_class, rating_progress
|
from judge.ratings import rating_class, rating_progress
|
||||||
|
from judge.tasks import import_users
|
||||||
from judge.utils.problems import contest_completed_ids, user_completed_ids
|
from judge.utils.problems import contest_completed_ids, user_completed_ids
|
||||||
from judge.utils.ranker import ranker
|
from judge.utils.ranker import ranker
|
||||||
from judge.utils.subscription import Subscription
|
from judge.utils.subscription import Subscription
|
||||||
from judge.utils.unicode import utf8text
|
from judge.utils.unicode import utf8text
|
||||||
from judge.utils.views import DiggPaginatorMixin, QueryStringSortMixin, TitleMixin, generic_message
|
from judge.utils.views import DiggPaginatorMixin, QueryStringSortMixin, TitleMixin, generic_message, SingleObjectFormView
|
||||||
from .contests import ContestRanking
|
from .contests import ContestRanking
|
||||||
|
|
||||||
__all__ = ['UserPage', 'UserAboutPage', 'UserProblemsPage', 'users', 'edit_profile']
|
__all__ = ['UserPage', 'UserAboutPage', 'UserProblemsPage', 'users', 'edit_profile']
|
||||||
|
@ -74,6 +78,11 @@ class UserPage(TitleMixin, UserMixin, DetailView):
|
||||||
return (_('My account') if self.request.user == self.object.user else
|
return (_('My account') if self.request.user == self.object.user else
|
||||||
_('User %s') % self.object.user.username)
|
_('User %s') % self.object.user.username)
|
||||||
|
|
||||||
|
def get_content_title(self):
|
||||||
|
username = self.object.user.username
|
||||||
|
css_class = self.object.css_class
|
||||||
|
return mark_safe(f'<span class="{css_class}">{username}</span>')
|
||||||
|
|
||||||
# TODO: the same code exists in problem.py, maybe move to problems.py?
|
# TODO: the same code exists in problem.py, maybe move to problems.py?
|
||||||
@cached_property
|
@cached_property
|
||||||
def profile(self):
|
def profile(self):
|
||||||
|
@ -126,6 +135,28 @@ EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||||
class UserAboutPage(UserPage):
|
class UserAboutPage(UserPage):
|
||||||
template_name = 'user/user-about.html'
|
template_name = 'user/user-about.html'
|
||||||
|
|
||||||
|
def get_awards(self, ratings):
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
sorted_ratings = sorted(ratings,
|
||||||
|
key=lambda x: (x.rank, -x.contest.end_time.timestamp()))
|
||||||
|
|
||||||
|
result['medals'] = [{
|
||||||
|
'label': rating.contest.name,
|
||||||
|
'ranking': rating.rank,
|
||||||
|
'link': reverse('contest_ranking', args=(rating.contest.key,)) + '#!' + self.object.username,
|
||||||
|
'date': date_format(rating.contest.end_time, _('M j, Y')),
|
||||||
|
} for rating in sorted_ratings if rating.rank <= 3]
|
||||||
|
|
||||||
|
num_awards = 0
|
||||||
|
for i in result:
|
||||||
|
num_awards += len(result[i])
|
||||||
|
|
||||||
|
if num_awards == 0:
|
||||||
|
result = None
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
context = super(UserAboutPage, self).get_context_data(**kwargs)
|
context = super(UserAboutPage, self).get_context_data(**kwargs)
|
||||||
ratings = context['ratings'] = self.object.ratings.order_by('-contest__end_time').select_related('contest') \
|
ratings = context['ratings'] = self.object.ratings.order_by('-contest__end_time').select_related('contest') \
|
||||||
|
@ -142,6 +173,8 @@ class UserAboutPage(UserPage):
|
||||||
'height': '%.3fem' % rating_progress(rating.rating),
|
'height': '%.3fem' % rating_progress(rating.rating),
|
||||||
} for rating in ratings]))
|
} for rating in ratings]))
|
||||||
|
|
||||||
|
context['awards'] = self.get_awards(ratings)
|
||||||
|
|
||||||
if ratings:
|
if ratings:
|
||||||
user_data = self.object.ratings.aggregate(Min('rating'), Max('rating'))
|
user_data = self.object.ratings.aggregate(Min('rating'), Max('rating'))
|
||||||
global_data = Rating.objects.aggregate(Min('rating'), Max('rating'))
|
global_data = Rating.objects.aggregate(Min('rating'), Max('rating'))
|
||||||
|
@ -285,7 +318,9 @@ def edit_profile(request):
|
||||||
form.fields['test_site'].initial = request.user.has_perm('judge.test_site')
|
form.fields['test_site'].initial = request.user.has_perm('judge.test_site')
|
||||||
|
|
||||||
tzmap = settings.TIMEZONE_MAP
|
tzmap = settings.TIMEZONE_MAP
|
||||||
|
print(settings.REGISTER_NAME_URL)
|
||||||
return render(request, 'user/edit-profile.html', {
|
return render(request, 'user/edit-profile.html', {
|
||||||
|
'edit_name_url': settings.REGISTER_NAME_URL,
|
||||||
'require_staff_2fa': settings.DMOJ_REQUIRE_STAFF_2FA,
|
'require_staff_2fa': settings.DMOJ_REQUIRE_STAFF_2FA,
|
||||||
'form': form, 'title': _('Edit profile'), 'profile': profile,
|
'form': form, 'title': _('Edit profile'), 'profile': profile,
|
||||||
'has_math_config': bool(settings.MATHOID_URL),
|
'has_math_config': bool(settings.MATHOID_URL),
|
||||||
|
@ -367,3 +402,56 @@ class UserLogoutView(TitleMixin, TemplateView):
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
auth_logout(request)
|
auth_logout(request)
|
||||||
return HttpResponseRedirect(request.get_full_path())
|
return HttpResponseRedirect(request.get_full_path())
|
||||||
|
|
||||||
|
|
||||||
|
class ImportUsersView(TitleMixin, TemplateView):
|
||||||
|
template_name = 'user/import/index.html'
|
||||||
|
title = _('Import Users')
|
||||||
|
|
||||||
|
def get(self, *args, **kwargs):
|
||||||
|
if self.request.user.is_superuser:
|
||||||
|
return super().get(self, *args, **kwargs)
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
|
|
||||||
|
def import_users_post_file(request):
|
||||||
|
if not request.user.is_superuser or request.method != 'POST':
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
users = import_users.csv_to_dict(request.FILES['csv_file'])
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
return JsonResponse({
|
||||||
|
'done': False,
|
||||||
|
'msg': 'No valid row found. Make sure row containing username.'
|
||||||
|
})
|
||||||
|
|
||||||
|
table_html = render_to_string('user/import/table_csv.html', {
|
||||||
|
'data': users
|
||||||
|
})
|
||||||
|
return JsonResponse({
|
||||||
|
'done': True,
|
||||||
|
'html': table_html,
|
||||||
|
'data': users
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def import_users_submit(request):
|
||||||
|
import json
|
||||||
|
if not request.user.is_superuser or request.method != 'POST':
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
|
users = json.loads(request.body)['users']
|
||||||
|
log = import_users.import_users(users)
|
||||||
|
return JsonResponse({
|
||||||
|
'msg': log
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def sample_import_users(request):
|
||||||
|
if not request.user.is_superuser or request.method != 'GET':
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
filename = 'import_sample.csv'
|
||||||
|
content = ','.join(import_users.fields) + '\n' + ','.join(import_users.descriptions)
|
||||||
|
response = HttpResponse(content, content_type='text/plain')
|
||||||
|
response['Content-Disposition'] = 'attachment; filename={0}'.format(filename)
|
||||||
|
return response
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Arabic, Saudi Arabia\n"
|
"Language-Team: Arabic, Saudi Arabia\n"
|
||||||
|
@ -10,7 +10,8 @@ msgstr ""
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
|
||||||
|
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||||
"X-Generator: crowdin.com\n"
|
"X-Generator: crowdin.com\n"
|
||||||
"X-Crowdin-Project: dmoj\n"
|
"X-Crowdin-Project: dmoj\n"
|
||||||
"X-Crowdin-Language: ar-SA\n"
|
"X-Crowdin-Language: ar-SA\n"
|
||||||
|
@ -31,4 +32,3 @@ msgstr[5] ""
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2020-04-08 21:06-0500\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: German\n"
|
"Language-Team: German\n"
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -8,7 +8,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2018-08-21 17:54-0400\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
|
@ -18,14 +18,14 @@ msgstr ""
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
#: .\resources\common.js:203
|
#: resources/common.js:207
|
||||||
msgctxt "time format with day"
|
msgctxt "time format with day"
|
||||||
msgid "%d day %h:%m:%s"
|
msgid "%d day %h:%m:%s"
|
||||||
msgid_plural "%d days %h:%m:%s"
|
msgid_plural "%d days %h:%m:%s"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: .\resources\common.js:206
|
#: resources/common.js:210
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:06\n"
|
"PO-Revision-Date: 2019-11-11 22:06\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Spanish\n"
|
"Language-Team: Spanish\n"
|
||||||
|
@ -27,4 +27,3 @@ msgstr[1] "%d días %h:%m:%s"
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr "%h:%m:%s"
|
msgstr "%h:%m:%s"
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: French\n"
|
"Language-Team: French\n"
|
||||||
|
@ -27,4 +27,3 @@ msgstr[1] "%d jours %h:%m:%s"
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr "%h:%m:%s"
|
msgstr "%h:%m:%s"
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Croatian\n"
|
"Language-Team: Croatian\n"
|
||||||
|
@ -10,7 +10,8 @@ msgstr ""
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
|
||||||
|
"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||||
"X-Generator: crowdin.com\n"
|
"X-Generator: crowdin.com\n"
|
||||||
"X-Crowdin-Project: dmoj\n"
|
"X-Crowdin-Project: dmoj\n"
|
||||||
"X-Crowdin-Language: hr\n"
|
"X-Crowdin-Language: hr\n"
|
||||||
|
@ -28,4 +29,3 @@ msgstr[2] "%d dana %h:%m:%s"
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr "%h:%m:%s"
|
msgstr "%h:%m:%s"
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Hungarian\n"
|
"Language-Team: Hungarian\n"
|
||||||
|
@ -27,4 +27,3 @@ msgstr[1] "%d nap %h:%m:%s"
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr "%h:%m:%s"
|
msgstr "%h:%m:%s"
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Italian\n"
|
"Language-Team: Italian\n"
|
||||||
|
@ -27,4 +27,3 @@ msgstr[1] ""
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Japanese\n"
|
"Language-Team: Japanese\n"
|
||||||
|
@ -26,4 +26,3 @@ msgstr[0] "%d 日 %h:%m:%s"
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr "%h:%m:%s"
|
msgstr "%h:%m:%s"
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Korean\n"
|
"Language-Team: Korean\n"
|
||||||
|
@ -26,4 +26,3 @@ msgstr[0] "%d일 %h:%m:%s"
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr "%h:%m:%s"
|
msgstr "%h:%m:%s"
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Lithuanian\n"
|
"Language-Team: Lithuanian\n"
|
||||||
|
@ -10,7 +10,8 @@ msgstr ""
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=4; plural=(n%10==1 && (n%100>19 || n%100<11) ? 0 : (n%10>=2 && n%10<=9) && (n%100>19 || n%100<11) ? 1 : n%1!=0 ? 2: 3);\n"
|
"Plural-Forms: nplurals=4; plural=(n%10==1 && (n%100>19 || n%100<11) ? 0 : (n"
|
||||||
|
"%10>=2 && n%10<=9) && (n%100>19 || n%100<11) ? 1 : n%1!=0 ? 2: 3);\n"
|
||||||
"X-Generator: crowdin.com\n"
|
"X-Generator: crowdin.com\n"
|
||||||
"X-Crowdin-Project: dmoj\n"
|
"X-Crowdin-Project: dmoj\n"
|
||||||
"X-Crowdin-Language: lt\n"
|
"X-Crowdin-Language: lt\n"
|
||||||
|
@ -29,4 +30,3 @@ msgstr[3] ""
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Dutch\n"
|
"Language-Team: Dutch\n"
|
||||||
|
@ -27,4 +27,3 @@ msgstr[1] ""
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Polish\n"
|
"Language-Team: Polish\n"
|
||||||
|
@ -10,7 +10,9 @@ msgstr ""
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
|
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n"
|
||||||
|
"%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n"
|
||||||
|
"%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
|
||||||
"X-Generator: crowdin.com\n"
|
"X-Generator: crowdin.com\n"
|
||||||
"X-Crowdin-Project: dmoj\n"
|
"X-Crowdin-Project: dmoj\n"
|
||||||
"X-Crowdin-Language: pl\n"
|
"X-Crowdin-Language: pl\n"
|
||||||
|
@ -29,4 +31,3 @@ msgstr[3] ""
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Portuguese, Brazilian\n"
|
"Language-Team: Portuguese, Brazilian\n"
|
||||||
|
@ -27,4 +27,3 @@ msgstr[1] ""
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:05\n"
|
"PO-Revision-Date: 2019-11-11 22:05\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Romanian\n"
|
"Language-Team: Romanian\n"
|
||||||
|
@ -10,7 +10,8 @@ msgstr ""
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n%100<20)) ? 1 : 2);\n"
|
"Plural-Forms: nplurals=3; plural=(n==1 ? 0 : (n==0 || (n%100>0 && n"
|
||||||
|
"%100<20)) ? 1 : 2);\n"
|
||||||
"X-Generator: crowdin.com\n"
|
"X-Generator: crowdin.com\n"
|
||||||
"X-Crowdin-Project: dmoj\n"
|
"X-Crowdin-Project: dmoj\n"
|
||||||
"X-Crowdin-Language: ro\n"
|
"X-Crowdin-Language: ro\n"
|
||||||
|
@ -28,4 +29,3 @@ msgstr[2] "%d zile %h:%m:%s"
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr "%h:%m:%s"
|
msgstr "%h:%m:%s"
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,7 @@ msgid ""
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: dmoj\n"
|
"Project-Id-Version: dmoj\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2019-10-01 23:12+0000\n"
|
"POT-Creation-Date: 2021-07-20 23:30+0700\n"
|
||||||
"PO-Revision-Date: 2019-11-11 22:06\n"
|
"PO-Revision-Date: 2019-11-11 22:06\n"
|
||||||
"Last-Translator: Icyene\n"
|
"Last-Translator: Icyene\n"
|
||||||
"Language-Team: Russian\n"
|
"Language-Team: Russian\n"
|
||||||
|
@ -10,7 +10,9 @@ msgstr ""
|
||||||
"MIME-Version: 1.0\n"
|
"MIME-Version: 1.0\n"
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 "
|
||||||
|
"&& n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 "
|
||||||
|
"&& n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
||||||
"X-Generator: crowdin.com\n"
|
"X-Generator: crowdin.com\n"
|
||||||
"X-Crowdin-Project: dmoj\n"
|
"X-Crowdin-Project: dmoj\n"
|
||||||
"X-Crowdin-Language: ru\n"
|
"X-Crowdin-Language: ru\n"
|
||||||
|
@ -29,4 +31,3 @@ msgstr[3] "%d дней %h:%m:%s"
|
||||||
msgctxt "time format without day"
|
msgctxt "time format without day"
|
||||||
msgid "%h:%m:%s"
|
msgid "%h:%m:%s"
|
||||||
msgstr "%h:%m:%s"
|
msgstr "%h:%m:%s"
|
||||||
|
|
||||||
|
|
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue