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.
|
||||||
|
@ -529,4 +521,13 @@ NEWSLETTER_EMAIL_DELAY = 0.1
|
||||||
NEWSLETTER_BATCH_DELAY = 60
|
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,9 +1,8 @@
|
||||||
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:
|
||||||
return url
|
return url
|
||||||
return camo_client.rewrite_url(url)
|
return camo_client.rewrite_url(url)
|
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))
|
||||||
|
|
|
@ -64,4 +64,4 @@ class MathRenderer(mistune.Renderer):
|
||||||
def math(self, math):
|
def math(self, math):
|
||||||
if self.mathoid is None or not math:
|
if self.mathoid is None or not math:
|
||||||
return r'\(%s\)' % mistune.escape(str(math))
|
return r'\(%s\)' % mistune.escape(str(math))
|
||||||
return self.mathoid.inline_math(math)
|
return self.mathoid.inline_math(math)
|
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
|
||||||
|
@ -219,6 +219,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:
|
||||||
|
|
299
judge/ratings.py
299
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']
|
||||||
|
|
||||||
|
|
||||||
|
@ -178,4 +213,4 @@ def rating_progress(rating):
|
||||||
return 1.0
|
return 1.0
|
||||||
prev = 0 if not level else RATING_VALUES[level - 1]
|
prev = 0 if not level else RATING_VALUES[level - 1]
|
||||||
next = RATING_VALUES[level]
|
next = RATING_VALUES[level]
|
||||||
return (rating - prev + 0.0) / (next - prev)
|
return (rating - prev + 0.0) / (next - prev)
|
|
@ -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()
|
||||||
|
@ -21,52 +17,4 @@ def generate_report(problem):
|
||||||
rate[i] = score[i] / total[i]
|
rate[i] = score[i] / total[i]
|
||||||
|
|
||||||
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:
|
||||||
|
@ -144,4 +146,4 @@ def hot_problems(duration, limit):
|
||||||
)).order_by('-ordering').defer('description')[:limit]
|
)).order_by('-ordering').defer('description')[:limit]
|
||||||
|
|
||||||
cache.set(cache_key, qs, 900)
|
cache.set(cache_key, qs, 900)
|
||||||
return qs
|
return qs
|
|
@ -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,10 +16,9 @@ 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,
|
||||||
'start_time': c.start_time.isoformat(),
|
'start_time': c.start_time.isoformat(),
|
||||||
|
@ -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)
|
||||||
|
@ -81,17 +82,14 @@ class PostList(ListView):
|
||||||
.annotate(points=Max('points'), latest=Max('date'))
|
.annotate(points=Max('points'), latest=Max('date'))
|
||||||
.order_by('-latest')
|
.order_by('-latest')
|
||||||
[:settings.DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT])
|
[:settings.DMOJ_BLOG_RECENTLY_ATTEMPTED_PROBLEMS_COUNT])
|
||||||
|
|
||||||
|
visible_contests = Contest.get_visible_contests(self.request.user).filter(is_visible=True) \
|
||||||
|
.order_by('start_time')
|
||||||
|
|
||||||
visible_contests = Contest.objects.filter(is_visible=True).order_by('start_time')
|
context['current_contests'] = visible_contests.filter(start_time__lte=now, end_time__gt=now)
|
||||||
q = Q(is_private=False, is_organization_private=False)
|
context['future_contests'] = visible_contests.filter(start_time__gt=now)
|
||||||
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()
|
|
||||||
|
|
||||||
|
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,18 +211,22 @@ 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,
|
||||||
contest.is_organization_private, contest.organizations.all())
|
contest.is_organization_private, contest.organizations.all())
|
||||||
|
@ -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"
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue