Merge branch 'LQDJudge:master' into master
This commit is contained in:
commit
84aabb9dd5
28 changed files with 932 additions and 532 deletions
|
@ -84,6 +84,7 @@ DMOJ_STATS_SUBMISSION_RESULT_COLORS = {
|
|||
"CE": "#42586d",
|
||||
"ERR": "#ffa71c",
|
||||
}
|
||||
DMOJ_PROFILE_IMAGE_ROOT = "profile_images"
|
||||
|
||||
MARKDOWN_STYLES = {}
|
||||
MARKDOWN_DEFAULT_STYLE = {}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.admin.models import LogEntry
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from judge.admin.comments import CommentAdmin
|
||||
from judge.admin.contest import ContestAdmin, ContestParticipationAdmin, ContestTagAdmin
|
||||
|
@ -11,7 +12,7 @@ from judge.admin.interface import (
|
|||
)
|
||||
from judge.admin.organization import OrganizationAdmin, OrganizationRequestAdmin
|
||||
from judge.admin.problem import ProblemAdmin, ProblemPointsVoteAdmin
|
||||
from judge.admin.profile import ProfileAdmin
|
||||
from judge.admin.profile import ProfileAdmin, UserAdmin
|
||||
from judge.admin.runtime import JudgeAdmin, LanguageAdmin
|
||||
from judge.admin.submission import SubmissionAdmin
|
||||
from judge.admin.taxon import ProblemGroupAdmin, ProblemTypeAdmin
|
||||
|
@ -66,3 +67,5 @@ admin.site.register(Submission, SubmissionAdmin)
|
|||
admin.site.register(Ticket, TicketAdmin)
|
||||
admin.site.register(VolunteerProblemVote, VolunteerProblemVoteAdmin)
|
||||
admin.site.register(Course)
|
||||
admin.site.unregister(User)
|
||||
admin.site.register(User, UserAdmin)
|
||||
|
|
|
@ -3,6 +3,7 @@ from django.forms import ModelForm
|
|||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext, gettext_lazy as _, ungettext
|
||||
from reversion.admin import VersionAdmin
|
||||
from django.contrib.auth.admin import UserAdmin as OldUserAdmin
|
||||
|
||||
from django_ace import AceWidget
|
||||
from judge.models import Profile
|
||||
|
@ -167,3 +168,38 @@ class ProfileAdmin(VersionAdmin):
|
|||
"javascript", request.profile.ace_theme
|
||||
)
|
||||
return form
|
||||
|
||||
|
||||
class UserAdmin(OldUserAdmin):
|
||||
# Customize the fieldsets for adding and editing users
|
||||
fieldsets = (
|
||||
(None, {"fields": ("username", "password")}),
|
||||
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
|
||||
(
|
||||
"Permissions",
|
||||
{
|
||||
"fields": (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
)
|
||||
},
|
||||
),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
|
||||
readonly_fields = ("last_login", "date_joined")
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = self.readonly_fields
|
||||
if not request.user.is_superuser:
|
||||
fields += (
|
||||
"is_staff",
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
"groups",
|
||||
"user_permissions",
|
||||
)
|
||||
return fields
|
||||
|
|
|
@ -16,7 +16,7 @@ class EventPoster(object):
|
|||
|
||||
def _connect(self):
|
||||
self._conn = pika.BlockingConnection(
|
||||
pika.URLParameters(settings.EVENT_DAEMON_AMQP)
|
||||
pika.URLParameters(settings.EVENT_DAEMON_AMQP),
|
||||
)
|
||||
self._chan = self._conn.channel()
|
||||
|
||||
|
@ -25,7 +25,7 @@ class EventPoster(object):
|
|||
id = int(time() * 1000000)
|
||||
self._chan.basic_publish(
|
||||
self._exchange,
|
||||
"",
|
||||
"#",
|
||||
json.dumps({"id": id, "channel": channel, "message": message}),
|
||||
)
|
||||
return id
|
||||
|
|
|
@ -50,6 +50,7 @@ from judge.widgets import (
|
|||
HeavySelect2Widget,
|
||||
Select2MultipleWidget,
|
||||
DateTimePickerWidget,
|
||||
ImageWidget,
|
||||
)
|
||||
from judge.tasks import rescore_contest
|
||||
|
||||
|
@ -78,12 +79,14 @@ class ProfileForm(ModelForm):
|
|||
"language",
|
||||
"ace_theme",
|
||||
"user_script",
|
||||
"profile_image",
|
||||
]
|
||||
widgets = {
|
||||
"user_script": AceWidget(theme="github"),
|
||||
"timezone": Select2Widget(attrs={"style": "width:200px"}),
|
||||
"language": Select2Widget(attrs={"style": "width:200px"}),
|
||||
"ace_theme": Select2Widget(attrs={"style": "width:200px"}),
|
||||
"profile_image": ImageWidget,
|
||||
}
|
||||
|
||||
has_math_config = bool(settings.MATHOID_URL)
|
||||
|
@ -100,12 +103,22 @@ class ProfileForm(ModelForm):
|
|||
def __init__(self, *args, **kwargs):
|
||||
user = kwargs.pop("user", None)
|
||||
super(ProfileForm, self).__init__(*args, **kwargs)
|
||||
self.fields["profile_image"].required = False
|
||||
|
||||
def clean_profile_image(self):
|
||||
profile_image = self.cleaned_data.get("profile_image")
|
||||
if profile_image:
|
||||
if profile_image.size > 5 * 1024 * 1024:
|
||||
raise ValidationError(
|
||||
_("File size exceeds the maximum allowed limit of 5MB.")
|
||||
)
|
||||
return profile_image
|
||||
|
||||
|
||||
def file_size_validator(file):
|
||||
limit = 1 * 1024 * 1024
|
||||
limit = 10 * 1024 * 1024
|
||||
if file.size > limit:
|
||||
raise ValidationError("File too large. Size should not exceed 1MB.")
|
||||
raise ValidationError("File too large. Size should not exceed 10MB.")
|
||||
|
||||
|
||||
class ProblemSubmitForm(ModelForm):
|
||||
|
@ -474,6 +487,15 @@ class ContestCloneForm(Form):
|
|||
max_length=20,
|
||||
validators=[RegexValidator("^[a-z0-9]+$", _("Contest id must be ^[a-z0-9]+$"))],
|
||||
)
|
||||
organization = ChoiceField(choices=(), required=True)
|
||||
|
||||
def __init__(self, *args, org_choices=(), profile=None, **kwargs):
|
||||
super(ContestCloneForm, self).__init__(*args, **kwargs)
|
||||
self.fields["organization"].widget = Select2Widget(
|
||||
attrs={"style": "width: 100%", "data-placeholder": _("Group")},
|
||||
)
|
||||
self.fields["organization"].choices = org_choices
|
||||
self.profile = profile
|
||||
|
||||
def clean_key(self):
|
||||
key = self.cleaned_data["key"]
|
||||
|
@ -481,6 +503,16 @@ class ContestCloneForm(Form):
|
|||
raise ValidationError(_("Contest with key already exists."))
|
||||
return key
|
||||
|
||||
def clean_organization(self):
|
||||
organization_id = self.cleaned_data["organization"]
|
||||
try:
|
||||
organization = Organization.objects.get(id=organization_id)
|
||||
except Exception:
|
||||
raise ValidationError(_("Group doesn't exist."))
|
||||
if not organization.admins.filter(id=self.profile.id).exists():
|
||||
raise ValidationError(_("You don't have permission in this group."))
|
||||
return organization
|
||||
|
||||
|
||||
class ProblemPointsVoteForm(ModelForm):
|
||||
class Meta:
|
||||
|
|
|
@ -9,14 +9,15 @@ from . import registry
|
|||
|
||||
|
||||
@registry.function
|
||||
def gravatar(email, size=80, default=None):
|
||||
if isinstance(email, Profile):
|
||||
def gravatar(profile, size=80, default=None, profile_image=None, email=None):
|
||||
if profile_image:
|
||||
return profile_image
|
||||
if profile and profile.profile_image:
|
||||
return profile.profile_image.url
|
||||
if profile:
|
||||
email = email or profile.user.email
|
||||
if default is None:
|
||||
default = email.mute
|
||||
email = email.user.email
|
||||
elif isinstance(email, AbstractUser):
|
||||
email = email.email
|
||||
|
||||
default = profile.mute
|
||||
gravatar_url = (
|
||||
"//www.gravatar.com/avatar/"
|
||||
+ hashlib.md5(utf8bytes(email.strip().lower())).hexdigest()
|
||||
|
|
21
judge/migrations/0162_profile_image.py
Normal file
21
judge/migrations/0162_profile_image.py
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 3.2.18 on 2023-08-24 00:50
|
||||
|
||||
from django.db import migrations, models
|
||||
import judge.models.profile
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("judge", "0161_auto_20230803_1536"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="profile",
|
||||
name="profile_image",
|
||||
field=models.ImageField(
|
||||
null=True, upload_to=judge.models.profile.profile_image_path
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,4 +1,5 @@
|
|||
from operator import mul
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
|
@ -27,6 +28,12 @@ class EncryptedNullCharField(EncryptedCharField):
|
|||
return super(EncryptedNullCharField, self).get_prep_value(value)
|
||||
|
||||
|
||||
def profile_image_path(profile, filename):
|
||||
tail = filename.split(".")[-1]
|
||||
new_filename = f"user_{profile.id}.{tail}"
|
||||
return os.path.join(settings.DMOJ_PROFILE_IMAGE_ROOT, new_filename)
|
||||
|
||||
|
||||
class Organization(models.Model):
|
||||
name = models.CharField(max_length=128, verbose_name=_("organization title"))
|
||||
slug = models.SlugField(
|
||||
|
@ -229,6 +236,7 @@ class Profile(models.Model):
|
|||
blank=True,
|
||||
help_text=_("Notes for administrators regarding this user."),
|
||||
)
|
||||
profile_image = models.ImageField(upload_to=profile_image_path, null=True)
|
||||
|
||||
@cached_property
|
||||
def organization(self):
|
||||
|
|
|
@ -453,9 +453,19 @@ class ContestClone(
|
|||
form_class = ContestCloneForm
|
||||
permission_required = "judge.clone_contest"
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["org_choices"] = tuple(
|
||||
Organization.objects.filter(admins=self.request.profile).values_list(
|
||||
"id", "name"
|
||||
)
|
||||
)
|
||||
kwargs["profile"] = self.request.profile
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
tags = self.object.tags.all()
|
||||
organizations = self.object.organizations.all()
|
||||
organization = form.cleaned_data["organization"]
|
||||
private_contestants = self.object.private_contestants.all()
|
||||
view_contest_scoreboard = self.object.view_contest_scoreboard.all()
|
||||
contest_problems = self.object.contest_problems.all()
|
||||
|
@ -469,7 +479,7 @@ class ContestClone(
|
|||
contest.save()
|
||||
|
||||
contest.tags.set(tags)
|
||||
contest.organizations.set(organizations)
|
||||
contest.organizations.set([organization])
|
||||
contest.private_contestants.set(private_contestants)
|
||||
contest.view_contest_scoreboard.set(view_contest_scoreboard)
|
||||
contest.authors.add(self.request.profile)
|
||||
|
@ -480,7 +490,14 @@ class ContestClone(
|
|||
ContestProblem.objects.bulk_create(contest_problems)
|
||||
|
||||
return HttpResponseRedirect(
|
||||
reverse("admin:judge_contest_change", args=(contest.id,))
|
||||
reverse(
|
||||
"organization_contest_edit",
|
||||
args=(
|
||||
organization.id,
|
||||
organization.slug,
|
||||
contest.key,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
from urllib.parse import urljoin
|
||||
|
||||
from django.db.models import F, Q
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.encoding import smart_text
|
||||
from django.views.generic.list import BaseListView
|
||||
from django.conf import settings
|
||||
|
||||
from chat_box.utils import encrypt_url
|
||||
|
||||
|
@ -54,7 +57,6 @@ class Select2View(BaseListView):
|
|||
class UserSelect2View(Select2View):
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.org_id = kwargs.get("org_id", request.GET.get("org_id", ""))
|
||||
print(self.org_id)
|
||||
return super(UserSelect2View, self).get(request, *args, **kwargs)
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -100,6 +102,21 @@ class UserSearchSelect2View(BaseListView):
|
|||
def get_queryset(self):
|
||||
return _get_user_queryset(self.term)
|
||||
|
||||
def get_json_result_from_object(self, user_tuple):
|
||||
pk, username, email, display_rank, profile_image = user_tuple
|
||||
return {
|
||||
"text": username,
|
||||
"id": username,
|
||||
"gravatar_url": gravatar(
|
||||
None,
|
||||
self.gravatar_size,
|
||||
self.gravatar_default,
|
||||
self.get_profile_image_url(profile_image),
|
||||
email,
|
||||
),
|
||||
"display_rank": display_rank,
|
||||
}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.request = request
|
||||
self.kwargs = kwargs
|
||||
|
@ -108,7 +125,7 @@ class UserSearchSelect2View(BaseListView):
|
|||
self.gravatar_default = request.GET.get("gravatar_default", None)
|
||||
|
||||
self.object_list = self.get_queryset().values_list(
|
||||
"pk", "user__username", "user__email", "display_rank"
|
||||
"pk", "user__username", "user__email", "display_rank", "profile_image"
|
||||
)
|
||||
|
||||
context = self.get_context_data()
|
||||
|
@ -116,15 +133,8 @@ class UserSearchSelect2View(BaseListView):
|
|||
return JsonResponse(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"text": username,
|
||||
"id": username,
|
||||
"gravatar_url": gravatar(
|
||||
email, self.gravatar_size, self.gravatar_default
|
||||
),
|
||||
"display_rank": display_rank,
|
||||
}
|
||||
for pk, username, email, display_rank in context["object_list"]
|
||||
self.get_json_result_from_object(user_tuple)
|
||||
for user_tuple in context["object_list"]
|
||||
],
|
||||
"more": context["page_obj"].has_next(),
|
||||
}
|
||||
|
@ -133,6 +143,11 @@ class UserSearchSelect2View(BaseListView):
|
|||
def get_name(self, obj):
|
||||
return str(obj)
|
||||
|
||||
def get_profile_image_url(self, profile_image):
|
||||
if profile_image:
|
||||
return urljoin(settings.MEDIA_URL, profile_image)
|
||||
return None
|
||||
|
||||
|
||||
class ContestUserSearchSelect2View(UserSearchSelect2View):
|
||||
def get_queryset(self):
|
||||
|
@ -161,43 +176,20 @@ class AssigneeSelect2View(UserSearchSelect2View):
|
|||
).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):
|
||||
class ChatUserSearchSelect2View(UserSearchSelect2View):
|
||||
def get_json_result_from_object(self, user_tuple):
|
||||
if not self.request.user.is_authenticated:
|
||||
raise Http404()
|
||||
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": [
|
||||
{
|
||||
pk, username, email, display_rank, profile_image = user_tuple
|
||||
return {
|
||||
"text": username,
|
||||
"id": encrypt_url(request.profile.id, pk),
|
||||
"id": encrypt_url(self.request.profile.id, pk),
|
||||
"gravatar_url": gravatar(
|
||||
email, self.gravatar_size, self.gravatar_default
|
||||
None,
|
||||
self.gravatar_size,
|
||||
self.gravatar_default,
|
||||
self.get_profile_image_url(profile_image),
|
||||
email,
|
||||
),
|
||||
"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)
|
||||
|
|
|
@ -402,12 +402,12 @@ class UserPerformancePointsAjax(UserProblemsPage):
|
|||
|
||||
@login_required
|
||||
def edit_profile(request):
|
||||
profile = Profile.objects.get(user=request.user)
|
||||
if profile.mute:
|
||||
raise Http404()
|
||||
profile = request.profile
|
||||
if request.method == "POST":
|
||||
form_user = UserForm(request.POST, instance=request.user)
|
||||
form = ProfileForm(request.POST, instance=profile, user=request.user)
|
||||
form = ProfileForm(
|
||||
request.POST, request.FILES, instance=profile, user=request.user
|
||||
)
|
||||
if form_user.is_valid() and form.is_valid():
|
||||
with transaction.atomic(), revisions.create_revision():
|
||||
form_user.save()
|
||||
|
|
|
@ -3,3 +3,4 @@ from judge.widgets.mixins import CompressorWidgetMixin
|
|||
from judge.widgets.pagedown import *
|
||||
from judge.widgets.select2 import *
|
||||
from judge.widgets.datetime import *
|
||||
from judge.widgets.image import *
|
||||
|
|
16
judge/widgets/image.py
Normal file
16
judge/widgets/image.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
from django import forms
|
||||
|
||||
|
||||
class ImageWidget(forms.ClearableFileInput):
|
||||
template_name = "widgets/image.html"
|
||||
|
||||
def __init__(self, attrs=None, width=80, height=80):
|
||||
self.width = width
|
||||
self.height = height
|
||||
super().__init__(attrs)
|
||||
|
||||
def get_context(self, name, value, attrs=None):
|
||||
context = super().get_context(name, value, attrs)
|
||||
context["widget"]["height"] = self.height
|
||||
context["widget"]["width"] = self.height
|
||||
return context
|
File diff suppressed because it is too large
Load diff
|
@ -313,6 +313,11 @@
|
|||
padding: 0.8em 0.2em 0.8em 1em;
|
||||
}
|
||||
|
||||
.sidebar-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.middle-content,
|
||||
.blog-sidebar,
|
||||
.right-sidebar {
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 542 KiB After Width: | Height: | Size: 73 KiB |
|
@ -263,7 +263,7 @@
|
|||
<span id="user-links">
|
||||
<ul><li><a href="javascript:void(0)">
|
||||
<span>
|
||||
<img src="{{ gravatar(request.user, 32) }}" height="24" width="24">{# -#}
|
||||
<img src="{{ gravatar(request.profile, 32) }}" height="24" width="24">{# -#}
|
||||
<span>
|
||||
<b class="{{request.profile.css_class}}">{{ request.user.username }}</b>
|
||||
</span>
|
||||
|
@ -271,24 +271,38 @@
|
|||
</a></li></ul>
|
||||
</span>
|
||||
<div class="dropdown" id="userlink_dropdown" role="tooptip">
|
||||
<div class="dropdown-item"><a href="{{ url('user_page') }}">{{ _('Profile') }}</a></div>
|
||||
<a href="{{ url('user_page') }}">
|
||||
<div class="dropdown-item">{{ _('Profile') }}</div>
|
||||
</a>
|
||||
{% if request.user.is_staff or request.user.is_superuser %}
|
||||
<div class="dropdown-item"><a href="{{ url('admin:index') }}">{{ _('Admin') }}</a></div>
|
||||
<a href="{{ url('admin:index') }}">
|
||||
<div class="dropdown-item">{{ _('Admin') }}</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if request.user.is_superuser %}
|
||||
<div class="dropdown-item"><a href="{{ url('internal_problem') }}">{{ _('Internal') }}</a></div>
|
||||
<div class="dropdown-item"><a href="{{ url('site_stats') }}">{{ _('Stats') }}</a></div>
|
||||
<a href="{{ url('internal_problem') }}">
|
||||
<div class="dropdown-item">{{ _('Internal') }}</div>
|
||||
</a>
|
||||
<a href="{{ url('site_stats') }}">
|
||||
<div class="dropdown-item">{{ _('Stats') }}</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="dropdown-item"><a href="{{ url('user_edit_profile') }}">{{ _('Edit profile') }}</a></div>
|
||||
<a href="{{ url('user_edit_profile') }}">
|
||||
<div class="dropdown-item">{{ _('Edit profile') }}</div>
|
||||
</a>
|
||||
{% if request.user.is_impersonate %}
|
||||
<div class="dropdown-item"><a href="{{ url('impersonate-stop') }}">Stop impersonating</a></div>
|
||||
<a href="{{ url('impersonate-stop') }}">
|
||||
<div class="dropdown-item">Stop impersonating</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" id="logout" class="red">
|
||||
<div class="dropdown-item">
|
||||
<a href="#" id="logout" class="red">{{ _('Log out') }}</a>
|
||||
{{ _('Log out') }}
|
||||
<form id="logout-form" action="{{ url('auth_logout') }}" method="POST">
|
||||
{% csrf_token %}
|
||||
</form>
|
||||
</div>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
</h3>
|
||||
<div class="sidebox-content">
|
||||
<div class="user-gravatar">
|
||||
<img src="{{ gravatar(request.user, 135) }}"
|
||||
<img src="{{ gravatar(request.profile, 135) }}"
|
||||
alt="gravatar" width="135px" height="135px">
|
||||
</div>
|
||||
<div class="recently-attempted">
|
||||
|
|
|
@ -42,8 +42,8 @@
|
|||
{% block left_sidebar %}
|
||||
<div class="left-sidebar">
|
||||
{{ make_tab_item('blog', 'fa fa-rss', url('home'), _('News')) }}
|
||||
{{ make_tab_item('comment', 'fa fa-comments', url('comment_feed'), _('Comments')) }}
|
||||
{{ make_tab_item('ticket', 'fa fa-question-circle', url('ticket_feed'), _('Tickets')) }}
|
||||
{{ make_tab_item('comment', 'fa fa-comments', url('comment_feed'), _('Comment')) }}
|
||||
{{ make_tab_item('ticket', 'fa fa-question-circle', url('ticket_feed'), _('Ticket')) }}
|
||||
{{ make_tab_item('event', 'fa fa-calendar', '#', _('Events')) }}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -499,18 +499,28 @@
|
|||
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
|
||||
load_dynamic_update({{last_msg}});
|
||||
|
||||
const button = document.querySelector('#emoji-button')
|
||||
const tooltip = document.querySelector('.tooltip')
|
||||
Popper.createPopper(button, tooltip)
|
||||
const button = document.querySelector('#emoji-button');
|
||||
const tooltip = document.querySelector('.tooltip');
|
||||
Popper.createPopper(button, tooltip, {
|
||||
placement: 'left-end',
|
||||
});
|
||||
|
||||
function toggleEmoji() {
|
||||
tooltip.classList.toggle('shown')
|
||||
}
|
||||
$('#emoji-button').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleEmoji();
|
||||
});
|
||||
|
||||
// Đóng bảng emoji khi click bất kỳ chỗ nào trên màn hình
|
||||
document.addEventListener("click", function(e) {
|
||||
if (!tooltip.contains(e.target)) {
|
||||
tooltip.classList.remove('shown'); // Ẩn bảng emoji
|
||||
}
|
||||
});
|
||||
|
||||
$('emoji-picker').on('emoji-click', function(e) {
|
||||
var $chat = $('#chat-input').get(0);
|
||||
insert_char_after_cursor($chat, e.detail.unicode);
|
||||
|
@ -629,7 +639,7 @@
|
|||
</div>
|
||||
<div id="chat-box">
|
||||
|
||||
<img src="{{static('loading.gif')}}" id="loader">
|
||||
<img src="{{static('loading.gif')}}" id="loader" height="2em">
|
||||
<ul id="chat-log" style="display: none">
|
||||
{% include 'chat/message_list.html' %}
|
||||
</ul>
|
||||
|
|
|
@ -43,9 +43,8 @@
|
|||
}
|
||||
|
||||
.tooltip {
|
||||
left: 120vh !important;
|
||||
transform: translate(100px, 0) !important;
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#loader {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% if other_user %}
|
||||
<div class="status-container" style="height: 100%">
|
||||
<img src="{{ gravatar(other_user.user, 135) }}" class="info-pic">
|
||||
<img src="{{ gravatar(other_user, 135) }}" class="info-pic">
|
||||
<svg style="position:absolute; height:100%; width: 100%">
|
||||
<circle class="info-circle"
|
||||
fill="{{'green' if other_online else 'red'}}"/>
|
||||
|
|
|
@ -25,17 +25,32 @@
|
|||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block js_media %}
|
||||
<script type="text/javascript">
|
||||
$(function() {
|
||||
$("#id_organization").select2();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<form id="contest-clone-panel" action="" method="post" class="form-area">
|
||||
{% csrf_token %}
|
||||
{% if form.errors %}
|
||||
{% if form.key.errors %}
|
||||
<div id="form-errors">
|
||||
{{ form.key.errors }}
|
||||
<div>{{ form.key.errors }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div><label class="inline-header grayed">{{ _('Enter a new key for the cloned contest:') }}</label></div>
|
||||
<div id="contest-key-container"><span class="fullwidth">{{ form.key }}</span></div>
|
||||
<div><label class="inline-header grayed">{{ _('Group:') }}</label></div>
|
||||
{{form.organization}}
|
||||
{% if form.organization.errors %}
|
||||
<div id="form-errors">
|
||||
<div>{{ form.organization.errors }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<hr>
|
||||
<button style="float: right;" type="submit">{{ _('Clone!') }}</button>
|
||||
</form>
|
||||
|
|
|
@ -118,7 +118,7 @@
|
|||
{% macro make_tab_item(name, fa, url, text) %}
|
||||
<div class="left-sidebar-item {% if page_type == name %}active{% endif %}" data-href="{{ url }}" id="{{ name }}-tab">
|
||||
<span class="sidebar-icon"><i class="{{ fa }}"></i></span>
|
||||
<span>{{ text }}</span>
|
||||
<span class="sidebar-text">{{ text }}</span>
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
|
|
|
@ -162,7 +162,7 @@
|
|||
<section class="message new-message">
|
||||
<div class="info">
|
||||
<a href="{{ url('user_page', request.user.username) }}" class="user">
|
||||
<img src="{{ gravatar(request.user, 135) }}" class="gravatar">
|
||||
<img src="{{ gravatar(request.profile, 135) }}" class="gravatar">
|
||||
<div class="username {{ request.profile.css_class }}">{{ request.user.username }}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
|
|
@ -43,9 +43,9 @@
|
|||
|
||||
#center-float {
|
||||
position: relative;
|
||||
margin: 0 auto auto -28.5em;
|
||||
left: 60%;
|
||||
width: 700px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
@ -79,11 +79,13 @@
|
|||
|
||||
{% block body %}
|
||||
<div id="center-float">
|
||||
<form id="edit-form" action="" method="post" class="form-area">
|
||||
{% if form.errors %}
|
||||
<form id="edit-form" action="" method="post" class="form-area" enctype="multipart/form-data">
|
||||
{% if form.errors or form_user.errors %}
|
||||
<div class="alert alert-danger alert-dismissable">
|
||||
<a href="#" class="close">x</a>
|
||||
{{ form.non_field_errors() }}
|
||||
{{ form.errors }}
|
||||
<br>
|
||||
{{ form_user.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
@ -98,6 +100,10 @@
|
|||
<td> {{ _('School') }}: </td>
|
||||
<td> {{ form_user.last_name }} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 1em">{{ _('Avatar') }}: </td>
|
||||
<td style="padding-top: 1em">{{ form.profile_image }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<hr>
|
||||
|
||||
|
@ -127,12 +133,6 @@
|
|||
<td><span class="fullwidth">{{ form.math_engine }}</span></td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<a href="http://www.gravatar.com/" title="{{ _('Change your avatar') }}"
|
||||
target="_blank" class="inline-header">{{ _('Change your avatar') }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2">
|
||||
<a href="{{ url('password_change') }}" class="inline-header">
|
||||
|
|
13
templates/widgets/image.html
Normal file
13
templates/widgets/image.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
{% if widget.is_initial %}
|
||||
<div>
|
||||
<a href="{{widget.value.url}}" target=_blank>
|
||||
<img src="{{widget.value.url}}" width="{{widget.width}}" height="{{widget.height}}" style="border-radius: 3px;">
|
||||
</a>
|
||||
<div>
|
||||
{{ widget.input_text }}:
|
||||
{% endif %}
|
||||
<input type="{{ widget.type }}" name="{{ widget.name }}">
|
||||
{% if widget.is_initial %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
198
websocket/daemon_amqp.js
Normal file
198
websocket/daemon_amqp.js
Normal file
|
@ -0,0 +1,198 @@
|
|||
var WebSocketServer = require('ws').Server;
|
||||
var set = require('simplesets').Set;
|
||||
var queue = require('qu');
|
||||
var amqp = require('amqp');
|
||||
var url = require('url');
|
||||
|
||||
if (typeof String.prototype.startsWith != 'function') {
|
||||
String.prototype.startsWith = function (str){
|
||||
return this.slice(0, str.length) == str;
|
||||
};
|
||||
}
|
||||
|
||||
const argv = require('yargs')
|
||||
.demandCommand(3)
|
||||
.strict()
|
||||
.usage('Usage: event [options] <amqp url> <exchange> <port>')
|
||||
.options({
|
||||
host: {
|
||||
default: '127.0.0.1',
|
||||
describe: 'websocket address to listen on'
|
||||
},
|
||||
http_host: {
|
||||
default: '127.0.0.1',
|
||||
describe: 'http address to listen on'
|
||||
},
|
||||
http_port: {
|
||||
default: null,
|
||||
describe: 'http port to listen on'
|
||||
},
|
||||
max_queue: {
|
||||
default: 10,
|
||||
describe: 'queue buffer size'
|
||||
},
|
||||
comet_timeout: {
|
||||
default: 60000,
|
||||
describe: 'comet long poll timeout'
|
||||
}
|
||||
})
|
||||
.argv;
|
||||
|
||||
var followers = new set();
|
||||
var pollers = new set();
|
||||
var messages = new queue();
|
||||
var max_queue = argv.max_queue;
|
||||
var comet_timeout = argv.comet_timeout;
|
||||
|
||||
var rabbitmq = amqp.createConnection({url: argv._[0]});
|
||||
|
||||
rabbitmq.on('error', function(e) {
|
||||
console.log('amqp connection error...', e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
rabbitmq.on('ready', function () {
|
||||
rabbitmq.queue('', {exclusive: true}, function (q) {
|
||||
q.bind(argv._[1], '#');
|
||||
q.subscribe(function (data) {
|
||||
message = JSON.parse(data.data.toString('utf8'));
|
||||
messages.push(message);
|
||||
if (messages.length > max_queue)
|
||||
messages.shift();
|
||||
followers.each(function (client) {
|
||||
client.got_message(message);
|
||||
});
|
||||
pollers.each(function (request) {
|
||||
request.got_message(message);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var wss = new WebSocketServer({host: argv.host, port: parseInt(argv._[2])});
|
||||
|
||||
messages.catch_up = function (client) {
|
||||
this.each(function (message) {
|
||||
if (message.id > client.last_msg)
|
||||
client.got_message(message);
|
||||
});
|
||||
};
|
||||
|
||||
wss.on('connection', function (socket) {
|
||||
socket.channel = null;
|
||||
socket.last_msg = 0;
|
||||
|
||||
var commands = {
|
||||
start_msg: function (request) {
|
||||
socket.last_msg = request.start;
|
||||
},
|
||||
set_filter: function (request) {
|
||||
var filter = {};
|
||||
if (Array.isArray(request.filter) && request.filter.length > 0 &&
|
||||
request.filter.every(function (channel, index, array) {
|
||||
if (typeof channel != 'string')
|
||||
return false;
|
||||
filter[channel] = true;
|
||||
return true;
|
||||
})) {
|
||||
socket.filter = filter;
|
||||
followers.add(socket);
|
||||
messages.catch_up(socket);
|
||||
} else {
|
||||
socket.send(JSON.stringify({
|
||||
status: 'error',
|
||||
code: 'invalid-filter',
|
||||
message: 'invalid filter: ' + request.filter
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.got_message = function (message) {
|
||||
if (message.channel in socket.filter)
|
||||
socket.send(JSON.stringify(message));
|
||||
socket.last_msg = message.id;
|
||||
};
|
||||
|
||||
socket.on('message', function (request) {
|
||||
try {
|
||||
request = JSON.parse(request);
|
||||
if (typeof request.command !== 'string')
|
||||
throw {message: 'no command specified'};
|
||||
} catch (err) {
|
||||
socket.send(JSON.stringify({
|
||||
status: 'error',
|
||||
code: 'syntax-error',
|
||||
message: err.message
|
||||
}));
|
||||
return;
|
||||
}
|
||||
request.command = request.command.replace(/-/g, '_');
|
||||
if (request.command in commands)
|
||||
commands[request.command](request);
|
||||
else
|
||||
socket.send(JSON.stringify({
|
||||
status: 'error',
|
||||
code: 'bad-command',
|
||||
message: 'bad command: ' + request.command
|
||||
}));
|
||||
});
|
||||
|
||||
socket.on('close', function(code, message) {
|
||||
followers.remove(socket);
|
||||
});
|
||||
});
|
||||
|
||||
if (argv.http_port !== null) {
|
||||
require('http').createServer(function (req, res) {
|
||||
var parts = url.parse(req.url, true);
|
||||
|
||||
if (!parts.pathname.startsWith('/channels/')) {
|
||||
res.writeHead(404, {'Content-Type': 'text/plain'});
|
||||
res.end('404 Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
var channels = parts.pathname.slice(10).split('|');
|
||||
if (channels.length == 1 && !channels[0].length) {
|
||||
res.writeHead(400, {'Content-Type': 'text/plain'});
|
||||
res.end('400 Bad Request');
|
||||
return;
|
||||
}
|
||||
|
||||
req.channels = {};
|
||||
req.last_msg = parseInt(parts.query.last);
|
||||
if (isNaN(req.last_msg)) req.last_msg = 0;
|
||||
|
||||
channels.forEach(function (channel) {
|
||||
req.channels[channel] = true;
|
||||
});
|
||||
|
||||
req.on('close', function () {
|
||||
pollers.remove(req);
|
||||
});
|
||||
|
||||
req.got_message = function (message) {
|
||||
if (message.channel in req.channels) {
|
||||
res.writeHead(200, {'Content-Type': 'application/json'});
|
||||
res.end(JSON.stringify(message));
|
||||
pollers.remove(req);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
var got = false;
|
||||
messages.each(function (message) {
|
||||
if (!got && message.id > req.last_msg)
|
||||
got = req.got_message(message);
|
||||
});
|
||||
if (!got) {
|
||||
pollers.add(req);
|
||||
res.setTimeout(comet_timeout, function () {
|
||||
pollers.remove(req);
|
||||
res.writeHead(504, {'Content-Type': 'application/json'});
|
||||
res.end('{"error": "timeout"}');
|
||||
});
|
||||
}
|
||||
}).listen(argv.http_port, argv.http_host);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue