This commit is contained in:
Le Van Duc 2023-09-06 07:23:21 +07:00
commit 19d61bf796
85 changed files with 4162 additions and 2699 deletions

191
README.md
View file

@ -31,6 +31,197 @@ Support plagiarism detection via [Stanford MOSS](https://theory.stanford.edu/~ai
Most of the setup are the same as DMOJ installations. You can view the installation guide of DMOJ here: https://docs.dmoj.ca/#/site/installation.
There is one minor change: Instead of `git clone https://github.com/DMOJ/site.git`, you clone this repo `git clone https://github.com/LQDJudge/online-judge.git`.
- Bước 1: cài các thư viện cần thiết
- $ ở đây nghĩa là sudo. Ví dụ dòng đầu nghĩa là chạy lệnh `sudo apt update`
```jsx
$ apt update
$ apt install git gcc g++ make python3-dev python3-pip libxml2-dev libxslt1-dev zlib1g-dev gettext curl redis-server
$ curl -sL https://deb.nodesource.com/setup_18.x | sudo -E bash -
$ apt install nodejs
$ npm install -g sass postcss-cli postcss autoprefixer
```
- Bước 2: tạo DB
- Server đang dùng MariaDB ≥ 10.5, các bạn cũng có thể dùng Mysql nếu bị conflict
- Nếu các bạn chạy lệnh dưới này xong mà version mariadb bị cũ (< 10.5) thì thể tra google cách cài MariaDB mới nhất (10.5 hoặc 10.6).
- Các bạn có thể thấy version MariaDB bằng cách gõ lệnh `sudo mysql` (Ctrl + C để quit)
```jsx
$ apt update
$ apt install mariadb-server libmysqlclient-dev
```
- Bước 3: tạo table trong DB
- Các bạn có thể thay tên table và password
```jsx
$ sudo mysql
mariadb> CREATE DATABASE dmoj DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_general_ci;
mariadb> GRANT ALL PRIVILEGES ON dmoj.* TO 'dmoj'@'localhost' IDENTIFIED BY '<password>';
mariadb> exit
```
- Bước 4: Cài đặt môi trường ảo (virtual env) và pull code
- Nếu `pip3 install mysqlclient` bị lỗi thì thử chạy `sudo pip3 install mysqlclient==2.1.1`
```jsx
$ python3 -m venv dmojsite
$ . dmojsite/bin/activate
(dmojsite) $ git clone https://github.com/LQDJudge/online-judge.git
(dmojsite) $ cd online-judge
(dmojsite) $ git submodule init
(dmojsite) $ git submodule update
(dmojsite) $ pip3 install -r requirements.txt
(dmojsite) $ pip3 install mysqlclient
(dmojsite) $ pre-commit install
```
- Bước 5: tạo local_settings.py. Đây là file để custom setting cho Django. Các bạn tạo file vào `online-judge/dmoj/local_settings.py`
- File mẫu: https://github.com/DMOJ/docs/blob/master/sample_files/local_settings.py
- Nếu bạn đổi tên hoặc mật khẩu table databases thì thay đổi thông tin tương ứng trong `Databases`
- Sau khi xong, chạy lệnh `(dmojsite) $ python3 manage.py check` để kiểm tra
- Bước 6: Compile CSS và translation
- Giải thích:
- Lệnh 1 và 2 gọi sau mỗi lần thay đổi 1 file css hoặc file js (file html thì không cần)
- Lệnh 3 và 4 gọi sau mỗi lần thay đổi file dịch
- Note: Sau khi chạy lệnh này, folder tương ứng với STATIC_ROOT trong local_settings phải được tạo. Nếu chưa được tạo thì mình cần tạo folder đó trước khi chạy 2 lệnh đầu.
```jsx
(dmojsite) $ ./make_style.sh
(dmojsite) $ python3 manage.py collectstatic
(dmojsite) $ python3 manage.py compilemessages
(dmojsite) $ python3 manage.py compilejsi18n
```
- Bước 7: Thêm dữ liệu vào DB
```jsx
(dmojsite) $ python3 manage.py migrate
(dmojsite) $ python3 manage.py loaddata navbar
(dmojsite) $ python3 manage.py loaddata language_small
(dmojsite) $ python3 manage.py loaddata demo
```
- Bước 8: Chạy site. Đến đây thì cơ bản đã hoàn thành (chưa có judge, websocket, celery). Các bạn có thể truy cập tại `localhost:8000`
```jsx
python3 manage.py runserver 0.0.0.0:8000
```
**Một số lưu ý:**
1. (WSL) có thể tải ứng dụng Terminal trong Windows Store
2. (WSL) mỗi lần mở ubuntu, các bạn cần chạy lệnh sau để mariadb khởi động: `sudo service mysql restart` (tương tự cho một số service khác như memcached, celery)
3. Sau khi cài đặt, các bạn chỉ cần activate virtual env và chạy lệnh runserver là ok
```jsx
. dmojsite/bin/activate
python3 manage.py runserver
```
5. Đối với nginx, sau khi config xong theo guide của DMOJ, bạn cần thêm location như sau để sử dụng được tính năng profile image, thay thế `path/to/oj` thành đường dẫn nơi bạn đã clone source code.
```
location /profile_images/ {
root /path/to/oj;
}
```
6. Quy trình dev:
1. Sau khi thay đổi code thì django tự build lại, các bạn chỉ cần F5
2. Một số style nằm trong các file .scss. Các bạn cần recompile css thì mới thấy được thay đổi.
**Optional:**
************Alias:************ Các bạn có thể lưu các alias này để sau này dùng cho nhanh
- mtrans: để generate translation khi các bạn add một string trong code
- trans: compile translation (sau khi bạn đã dịch tiếng Việt)
- cr: chuyển tới folder OJ
- pr: chạy server
- sm: restart service (chủ yếu dùng cho WSL)
- sd: activate virtual env
- css: compile các file css
```jsx
alias mtrans='python3 manage.py makemessages -l vi && python3 manage.py makedmojmessages -l vi'
alias pr='python3 manage.py runserver'
alias sd='source ~/LQDOJ/dmojsite/bin/activate'
alias sm='sudo service mysql restart && sudo service redis-server start && sudo service memcached start'
alias trans='python3 manage.py compilemessages -l vi && python3 manage.py compilejsi18n -l vi'
alias cr='cd ~/LQDOJ/online-judge'
alias css='./make_style.sh && python3 manage.py collectstatic --noinput'
```
**Memcached:** dùng cho in-memory cache
```jsx
$ sudo apt install memcached
```
**Websocket:** dùng để live update (như chat)
- Tạo file online-judge/websocket/config.js
```jsx
module.exports = {
get_host: '127.0.0.1',
get_port: 15100,
post_host: '127.0.0.1',
post_port: 15101,
http_host: '127.0.0.1',
http_port: 15102,
long_poll_timeout: 29000,
};
```
- Cài các thư viện
```jsx
(dmojsite) $ npm install qu ws simplesets
(dmojsite) $ pip3 install websocket-client
```
- Khởi động (trong 1 tab riêng)
```jsx
(dmojsite) $ node websocket/daemon.js
```
**************Celery:************** (dùng cho một số task như batch rejudge_
```jsx
celery -A dmoj_celery worker
```
**************Judge:**************
- Cài đặt ở 1 folder riêng bên ngoài site:
```jsx
$ apt install python3-dev python3-pip build-essential libseccomp-dev
$ git clone https://github.com/LQDJudge/judge-server.git
$ cd judge-server
$ sudo pip3 install -e .
```
- Tạo một file judge.yml ở bên ngoài folder judge-server (file mẫu https://github.com/DMOJ/docs/blob/master/sample_files/judge_conf.yml)
- Thêm judge vào site bằng UI: Admin → Judge → Thêm Judge → nhập id và key (chỉ cần thêm 1 lần) hoặc dùng lệnh `(dmojsite) $ python3 managed.py addjudge <id> <key>`.
- Chạy Bridge (cầu nối giữa judge và site) trong 1 tab riêng trong folder online-judge:
```jsx
(dmojsite) $ python3 managed.py runbridged
```
- Khởi động Judge (trong 1 tab riêng):
```jsx
$ dmoj -c judge.yml localhost
```
- Lưu ý: mỗi lần sau này muốn chạy judge thì mở 1 tab cho bridge và n tab cho judge. Mỗi judge cần 1 file yml khác nhau (chứa authentication khác nhau)
### Some frequent difficulties when installation:
1. Missing the `local_settings.py`. You need to copy the `local_settings.py` in order to pass the check.

View file

@ -0,0 +1,20 @@
# Generated by Django 3.2.18 on 2023-08-28 01:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("chat_box", "0012_auto_20230308_1417"),
]
operations = [
migrations.AlterField(
model_name="message",
name="time",
field=models.DateTimeField(
auto_now_add=True, db_index=True, verbose_name="posted time"
),
),
]

View file

@ -0,0 +1,38 @@
# Generated by Django 3.2.18 on 2023-08-28 06:02
from django.db import migrations, models
def migrate(apps, schema_editor):
UserRoom = apps.get_model("chat_box", "UserRoom")
Message = apps.get_model("chat_box", "Message")
for ur in UserRoom.objects.all():
if not ur.room:
continue
messages = ur.room.message_set
last_msg = messages.first()
try:
if last_msg and last_msg.author != ur.user:
ur.unread_count = messages.filter(time__gte=ur.last_seen).count()
else:
ur.unread_count = 0
ur.save()
except:
continue
class Migration(migrations.Migration):
dependencies = [
("chat_box", "0013_alter_message_time"),
]
operations = [
migrations.AddField(
model_name="userroom",
name="unread_count",
field=models.IntegerField(db_index=True, default=0),
),
migrations.RunPython(migrate, migrations.RunPython.noop, atomic=True),
]

View file

@ -1,9 +1,10 @@
from django.db import models
from django.db.models import CASCADE
from django.db.models import CASCADE, Q
from django.utils.translation import gettext_lazy as _
from judge.models.profile import Profile
from judge.caching import cache_wrapper
__all__ = ["Message", "Room", "UserRoom", "Ignore"]
@ -17,19 +18,28 @@ class Room(models.Model):
Profile, related_name="user_two", verbose_name="user 2", on_delete=CASCADE
)
@cache_wrapper(prefix="Rc")
def contain(self, profile):
return self.user_one == profile or self.user_two == profile
@cache_wrapper(prefix="Rou")
def other_user(self, profile):
return self.user_one if profile == self.user_two else self.user_two
@cache_wrapper(prefix="Rus")
def users(self):
return [self.user_one, self.user_two]
@cache_wrapper(prefix="Rlmb")
def last_message_body(self):
return self.message_set.first().body
class Message(models.Model):
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, db_index=True
)
body = models.TextField(verbose_name=_("body of comment"), max_length=8192)
hidden = models.BooleanField(verbose_name="is hidden", default=False)
room = models.ForeignKey(
@ -56,6 +66,7 @@ class UserRoom(models.Model):
Room, verbose_name="room id", on_delete=CASCADE, default=None, null=True
)
last_seen = models.DateTimeField(verbose_name=_("last seen"), auto_now_add=True)
unread_count = models.IntegerField(default=0, db_index=True)
class Meta:
unique_together = ("user", "room")
@ -74,11 +85,9 @@ class Ignore(models.Model):
@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()
)
return current_user.ignored_chat_users.ignored_users.filter(
id=new_friend.id
).exists()
except:
return False
@ -89,6 +98,16 @@ class Ignore(models.Model):
except:
return Profile.objects.none()
@classmethod
def get_ignored_rooms(self, user):
try:
ignored_users = self.objects.get(user=user).ignored_users.all()
return Room.objects.filter(Q(user_one=user) | Q(user_two=user)).filter(
Q(user_one__in=ignored_users) | Q(user_two__in=ignored_users)
)
except:
return Room.objects.none()
@classmethod
def add_ignore(self, current_user, friend):
ignore, created = self.objects.get_or_create(user=current_user)

View file

@ -1,10 +1,12 @@
from cryptography.fernet import Fernet
import hmac
import hashlib
from django.conf import settings
from django.db.models import OuterRef, Count, Subquery, IntegerField
from django.db.models import OuterRef, Count, Subquery, IntegerField, Q
from django.db.models.functions import Coalesce
from chat_box.models import Ignore, Message, UserRoom
from chat_box.models import Ignore, Message, UserRoom, Room
secret_key = settings.CHAT_SECRET_KEY
fernet = Fernet(secret_key)
@ -24,25 +26,22 @@ def decrypt_url(message_encrypted):
return None, None
def get_unread_boxes(profile):
ignored_users = Ignore.get_ignored_users(profile)
mess = (
Message.objects.filter(room=OuterRef("room"), time__gte=OuterRef("last_seen"))
.exclude(author=profile)
.exclude(author__in=ignored_users)
.order_by()
.values("room")
.annotate(unread_count=Count("pk"))
.values("unread_count")
def encrypt_channel(channel):
return (
hmac.new(
settings.CHAT_SECRET_KEY.encode(),
channel.encode(),
hashlib.sha512,
).hexdigest()[:16]
+ "%s" % channel
)
def get_unread_boxes(profile):
ignored_rooms = Ignore.get_ignored_rooms(profile)
unread_boxes = (
UserRoom.objects.filter(user=profile, room__isnull=False)
.annotate(
unread_count=Coalesce(Subquery(mess, output_field=IntegerField()), 0),
)
.filter(unread_count__gte=1)
UserRoom.objects.filter(user=profile, unread_count__gt=0)
.exclude(room__in=ignored_rooms)
.count()
)

View file

@ -21,6 +21,8 @@ from django.db.models import (
Exists,
Count,
IntegerField,
F,
Max,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@ -34,7 +36,7 @@ from judge.jinja2.gravatar import gravatar
from judge.models import Friend
from chat_box.models import Message, Profile, Room, UserRoom, Ignore
from chat_box.utils import encrypt_url, decrypt_url
from chat_box.utils import encrypt_url, decrypt_url, encrypt_channel
import json
@ -49,7 +51,8 @@ class ChatView(ListView):
self.room_id = None
self.room = None
self.messages = None
self.page_size = 20
self.first_page_size = 20 # only for first request
self.follow_up_page_size = 50
def get_queryset(self):
return self.messages
@ -63,10 +66,12 @@ class ChatView(ListView):
def get(self, request, *args, **kwargs):
request_room = kwargs["room_id"]
page_size = self.follow_up_page_size
try:
last_id = int(request.GET.get("last_id"))
except Exception:
last_id = 1e15
page_size = self.first_page_size
only_messages = request.GET.get("only_messages")
if request_room:
@ -80,11 +85,12 @@ class ChatView(ListView):
request_room = None
self.room_id = request_room
self.messages = Message.objects.filter(
hidden=False, room=self.room_id, id__lt=last_id
)[: self.page_size]
self.messages = (
Message.objects.filter(hidden=False, room=self.room_id, id__lt=last_id)
.select_related("author", "author__user")
.defer("author__about", "author__user_script")[:page_size]
)
if not only_messages:
update_last_seen(request, **kwargs)
return super().get(request, *args, **kwargs)
return render(
@ -101,10 +107,14 @@ class ChatView(ListView):
context["title"] = self.title
context["last_msg"] = event.last()
context["status_sections"] = get_status_context(self.request)
context["status_sections"] = get_status_context(self.request.profile)
context["room"] = self.room_id
context["has_next"] = self.has_next()
context["unread_count_lobby"] = get_unread_count(None, self.request.profile)
context["chat_channel"] = encrypt_channel(
"chat_" + str(self.request.profile.id)
)
context["chat_lobby_channel"] = encrypt_channel("chat_lobby")
if self.room:
users_room = [self.room.user_one, self.room.user_two]
users_room.remove(self.request.profile)
@ -187,7 +197,7 @@ def post_message(request):
if not room:
event.post(
"chat_lobby",
encrypt_channel("chat_lobby"),
{
"type": "lobby",
"author_id": request.profile.id,
@ -197,9 +207,10 @@ def post_message(request):
},
)
else:
Room.last_message_body.dirty(room)
for user in room.users():
event.post(
"chat_" + str(user.id),
encrypt_channel("chat_" + str(user.id)),
{
"type": "private",
"author_id": request.profile.id,
@ -208,6 +219,10 @@ def post_message(request):
"tmp_id": request.POST.get("tmp_id"),
},
)
if user != request.profile:
UserRoom.objects.filter(user=user, room=room).update(
unread_count=F("unread_count") + 1
)
return JsonResponse(ret)
@ -254,35 +269,33 @@ def update_last_seen(request, **kwargs):
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))
room = Room.objects.filter(id=int(room_id)).first()
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.unread_count = 0
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()
last_5_minutes = timezone.now() - timezone.timedelta(minutes=5)
return Profile.objects.filter(last_access__gte=last_5_minutes).count()
def get_user_online_status(user):
time_diff = timezone.now() - user.last_access
is_online = time_diff <= timezone.timedelta(minutes=2)
is_online = time_diff <= timezone.timedelta(minutes=5)
return is_online
@ -319,47 +332,67 @@ def user_online_status_ajax(request):
)
def get_online_status(request_user, queryset, rooms=None):
if not queryset:
def get_online_status(profile, other_profile_ids, rooms=None):
if not other_profile_ids:
return None
last_two_minutes = timezone.now() - timezone.timedelta(minutes=2)
joined_ids = ",".join([str(id) for id in other_profile_ids])
other_profiles = Profile.objects.raw(
f"SELECT * from judge_profile where id in ({joined_ids}) order by field(id,{joined_ids})"
)
last_5_minutes = timezone.now() - timezone.timedelta(minutes=5)
ret = []
if rooms:
unread_count = get_unread_count(rooms, request_user)
unread_count = get_unread_count(rooms, profile)
count = {}
last_msg = {}
room_of_user = {}
for i in unread_count:
count[i["other_user"]] = i["unread_count"]
room = Room.objects.get(id=i["room"])
other_profile = room.other_user(profile)
count[other_profile.id] = i["unread_count"]
for room in rooms:
room = Room.objects.get(id=room)
other_profile = room.other_user(profile)
last_msg[other_profile.id] = room.last_message_body()
room_of_user[other_profile.id] = room.id
for user in queryset:
for other_profile in other_profiles:
is_online = False
if user.last_access >= last_two_minutes:
if other_profile.last_access >= last_5_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)
user_dict = {"user": other_profile, "is_online": is_online}
if rooms:
user_dict.update(
{
"unread_count": count.get(other_profile.id),
"last_msg": last_msg.get(other_profile.id),
"room": room_of_user.get(other_profile.id),
}
)
user_dict["url"] = encrypt_url(profile.id, other_profile.id)
ret.append(user_dict)
return ret
def get_status_context(request, include_ignored=False):
def get_status_context(profile, include_ignored=False):
if include_ignored:
ignored_users = Profile.objects.none()
ignored_users = []
queryset = Profile.objects
else:
ignored_users = Ignore.get_ignored_users(request.profile)
ignored_users = list(
Ignore.get_ignored_users(profile).values_list("id", flat=True)
)
queryset = Profile.objects.exclude(id__in=ignored_users)
last_two_minutes = timezone.now() - timezone.timedelta(minutes=2)
last_5_minutes = timezone.now() - timezone.timedelta(minutes=5)
recent_profile = (
Room.objects.filter(Q(user_one=request.profile) | Q(user_two=request.profile))
Room.objects.filter(Q(user_one=profile) | Q(user_two=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"),
When(user_one=profile, then="user_two"),
default="user_one",
),
)
@ -369,50 +402,36 @@ def get_status_context(request, include_ignored=False):
.values("other_user", "id")[:20]
)
recent_profile_id = [str(i["other_user"]) for i in recent_profile]
joined_id = ",".join(recent_profile_id)
recent_profile_ids = [str(i["other_user"]) for i in recent_profile]
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)
.exclude(id__in=recent_profile_ids)
.values_list("id", flat=True)
)
all_user_status = (
queryset.filter(display_rank="user", last_access__gte=last_two_minutes)
queryset.filter(last_access__gte=last_5_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]
.exclude(id__in=recent_profile_ids)
.values_list("id", flat=True)[: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),
"user_list": get_online_status(profile, recent_profile_ids, recent_rooms),
},
{
"title": "Admin",
"user_list": get_online_status(request.profile, admin_list),
"user_list": get_online_status(profile, admin_list),
},
{
"title": "Other",
"user_list": get_online_status(request.profile, all_user_status),
"user_list": get_online_status(profile, all_user_status),
},
]
@ -423,7 +442,7 @@ def online_status_ajax(request):
request,
"chat/online_status.html",
{
"status_sections": get_status_context(request),
"status_sections": get_status_context(request.profile),
"unread_count_lobby": get_unread_count(None, request.profile),
},
)
@ -447,7 +466,6 @@ def get_or_create_room(request):
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()
@ -475,48 +493,22 @@ def get_or_create_room(request):
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")
)
return UserRoom.objects.filter(
user=user, room__in=rooms, unread_count__gt=0
).values("unread_count", "room")
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")
)
user_room = UserRoom.objects.filter(user=user, room__isnull=True).first()
if not user_room:
return 0
last_seen = user_room.last_seen
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)
Message.objects.filter(room__isnull=True, time__gte=last_seen)
.exclude(author=user)
.exclude(hidden=True)
.count()
)
return res[0] if len(res) else 0
return res
@login_required

View file

@ -9,6 +9,7 @@ https://docs.djangoproject.com/en/1.11/ref/settings/
"""
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import datetime
import os
import tempfile
@ -83,6 +84,7 @@ DMOJ_STATS_SUBMISSION_RESULT_COLORS = {
"CE": "#42586d",
"ERR": "#ffa71c",
}
DMOJ_PROFILE_IMAGE_ROOT = "profile_images"
MARKDOWN_STYLES = {}
MARKDOWN_DEFAULT_STYLE = {}
@ -475,10 +477,16 @@ ML_OUTPUT_PATH = None
# Use subdomain for organizations
USE_SUBDOMAIN = False
# Chat
CHAT_SECRET_KEY = "QUdVFsxk6f5-Hd8g9BXv81xMqvIZFRqMl-KbRzztW-U="
# Nginx
META_REMOTE_ADDRESS_KEY = "REMOTE_ADDR"
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
try:
with open(os.path.join(os.path.dirname(__file__), "local_settings.py")) as f:
exec(f.read(), globals())
except IOError:
pass
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"

View file

@ -65,6 +65,7 @@ from judge.views import (
internal,
resolver,
course,
email,
)
from judge.views.problem_data import (
ProblemDataView,
@ -104,19 +105,19 @@ register_patterns = [
# confusing 404.
url(
r"^activate/(?P<activation_key>\w+)/$",
ActivationView.as_view(title="Activation key invalid"),
ActivationView.as_view(title=_("Activation key invalid")),
name="registration_activate",
),
url(
r"^register/$",
RegistrationView.as_view(title="Register"),
RegistrationView.as_view(title=_("Register")),
name="registration_register",
),
url(
r"^register/complete/$",
TitledTemplateView.as_view(
template_name="registration/registration_complete.html",
title="Registration Completed",
title=_("Registration Completed"),
),
name="registration_complete",
),
@ -124,7 +125,7 @@ register_patterns = [
r"^register/closed/$",
TitledTemplateView.as_view(
template_name="registration/registration_closed.html",
title="Registration not allowed",
title=_("Registration not allowed"),
),
name="registration_disallowed",
),
@ -183,6 +184,17 @@ register_patterns = [
),
name="password_reset_done",
),
url(r"^email/change/$", email.email_change_view, name="email_change"),
url(
r"^email/change/verify/(?P<uidb64>[0-9A-Za-z]+)-(?P<token>.+)/$",
email.verify_email_view,
name="email_change_verify",
),
url(
r"^email/change/pending$",
email.email_change_pending_view,
name="email_change_pending",
),
url(r"^social/error/$", register.social_auth_error, name="social_auth_error"),
url(r"^2fa/$", totp.TOTPLoginView.as_view(), name="login_2fa"),
url(r"^2fa/enable/$", totp.TOTPEnableView.as_view(), name="enable_2fa"),

View file

@ -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)

View file

@ -71,7 +71,6 @@ class ContestProblemInlineForm(ModelForm):
"hidden_subtasks": TextInput(attrs={"size": "3"}),
"points": TextInput(attrs={"size": "1"}),
"order": TextInput(attrs={"size": "1"}),
"output_prefix_override": TextInput(attrs={"size": "1"}),
}
@ -86,7 +85,7 @@ class ContestProblemInline(admin.TabularInline):
"is_pretested",
"max_submissions",
"hidden_subtasks",
"output_prefix_override",
"show_testcases",
"order",
"rejudge_column",
)

View file

@ -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

View file

@ -5,9 +5,10 @@ from django.db.models.query import QuerySet
import hashlib
MAX_NUM_CHAR = 15
NONE_RESULT = "__None__"
def cache_wrapper(prefix, timeout=86400):
def cache_wrapper(prefix, timeout=None):
def arg_to_str(arg):
if hasattr(arg, "id"):
return str(arg.id)
@ -31,8 +32,11 @@ def cache_wrapper(prefix, timeout=86400):
cache_key = get_key(func, *args, **kwargs)
result = cache.get(cache_key)
if result is not None:
if result == NONE_RESULT:
result = None
return result
if result is None:
result = NONE_RESULT
result = func(*args, **kwargs)
cache.set(cache_key, result, timeout)
return result

View file

@ -4,4 +4,5 @@ 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.new_ioi import NewIOIContestFormat
from judge.contest_format.ultimate import UltimateContestFormat
from judge.contest_format.registry import choices, formats

View file

@ -0,0 +1,55 @@
from django.utils.translation import gettext_lazy
from judge.contest_format.ioi import IOIContestFormat
from judge.contest_format.registry import register_contest_format
from django.db.models import Min, OuterRef, Subquery
# This contest format only counts last submission for each problem.
@register_contest_format("ultimate")
class UltimateContestFormat(IOIContestFormat):
name = gettext_lazy("Ultimate")
def update_participation(self, participation):
cumtime = 0
score = 0
format_data = {}
queryset = participation.submissions
if self.contest.freeze_after:
queryset = queryset.filter(
submission__date__lt=participation.start + self.contest.freeze_after
)
queryset = (
queryset.values("problem_id")
.filter(
id=Subquery(
queryset.filter(problem_id=OuterRef("problem_id"))
.order_by("-id")
.values("id")[:1]
)
)
.values_list("problem_id", "submission__date", "points")
)
for problem_id, time, points in queryset:
if self.config["cumtime"]:
dt = (time - participation.start).total_seconds()
if points:
cumtime += dt
else:
dt = 0
format_data[str(problem_id)] = {
"time": dt,
"points": points,
}
score += points
self.handle_frozen_state(participation, format_data)
participation.cumtime = max(cumtime, 0)
participation.score = round(score, self.contest.points_precision)
participation.tiebreaker = 0
participation.format_data = format_data
participation.save()

View file

@ -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

View file

@ -50,6 +50,7 @@ from judge.widgets import (
HeavySelect2Widget,
Select2MultipleWidget,
DateTimePickerWidget,
ImageWidget,
)
from judge.tasks import rescore_contest
@ -78,12 +79,16 @@ class ProfileForm(ModelForm):
"language",
"ace_theme",
"user_script",
"profile_image",
"css_background",
]
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,
"css_background": forms.TextInput(),
}
has_math_config = bool(settings.MATHOID_URL)
@ -100,12 +105,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 +489,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 +505,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:
@ -496,19 +530,47 @@ class ContestProblemForm(ModelForm):
"problem",
"points",
"partial",
"output_prefix_override",
"show_testcases",
"max_submissions",
)
widgets = {
"problem": HeavySelect2Widget(
data_view="problem_select2", attrs={"style": "width:100%"}
data_view="problem_select2", attrs={"style": "width: 100%"}
),
}
class ContestProblemModelFormSet(BaseModelFormSet):
def is_valid(self):
valid = super().is_valid()
if not valid:
return valid
problems = set()
duplicates = []
for form in self.forms:
if form.cleaned_data and not form.cleaned_data.get("DELETE", False):
problem = form.cleaned_data.get("problem")
if problem in problems:
duplicates.append(problem)
else:
problems.add(problem)
if duplicates:
for form in self.forms:
problem = form.cleaned_data.get("problem")
if problem in duplicates:
form.add_error("problem", _("This problem is duplicated."))
return False
return True
class ContestProblemFormSet(
formset_factory(
ContestProblemForm, formset=BaseModelFormSet, extra=6, can_delete=True
ContestProblemForm, formset=ContestProblemModelFormSet, extra=6, can_delete=True
)
):
model = ContestProblem

View file

@ -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()

View 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
),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-08-25 00:19
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("judge", "0162_profile_image"),
]
operations = [
migrations.AddField(
model_name="profile",
name="email_change_pending",
field=models.EmailField(blank=True, max_length=254, null=True),
),
]

View file

@ -0,0 +1,30 @@
# Generated by Django 3.2.18 on 2023-08-25 23:03
from django.db import migrations, models
def migrate_show_testcases(apps, schema_editor):
ContestProblem = apps.get_model("judge", "ContestProblem")
for c in ContestProblem.objects.all():
if c.output_prefix_override == 1:
c.show_testcases = True
c.save()
class Migration(migrations.Migration):
dependencies = [
("judge", "0163_email_change"),
]
operations = [
migrations.AddField(
model_name="contestproblem",
name="show_testcases",
field=models.BooleanField(default=False, verbose_name="visible testcases"),
),
migrations.RunPython(
migrate_show_testcases, migrations.RunPython.noop, atomic=True
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.2.18 on 2023-08-25 23:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("judge", "0164_show_testcase"),
]
operations = [
migrations.RemoveField(
model_name="contestproblem",
name="output_prefix_override",
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 3.2.18 on 2023-08-28 01:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("judge", "0165_drop_output_prefix_override"),
]
operations = [
migrations.AlterField(
model_name="profile",
name="display_rank",
field=models.CharField(
choices=[
("user", "Normal User"),
("setter", "Problem Setter"),
("admin", "Admin"),
],
db_index=True,
default="user",
max_length=10,
verbose_name="display rank",
),
),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 3.2.18 on 2023-09-01 00:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("judge", "0166_display_rank_index"),
]
operations = [
migrations.AlterField(
model_name="contest",
name="format_name",
field=models.CharField(
choices=[
("atcoder", "AtCoder"),
("default", "Default"),
("ecoo", "ECOO"),
("icpc", "ICPC"),
("ioi", "IOI"),
("ioi16", "New IOI"),
("ultimate", "Ultimate"),
],
default="default",
help_text="The contest format module to use.",
max_length=32,
verbose_name="contest format",
),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 3.2.18 on 2023-09-02 00:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("judge", "0167_ultimate_contest_format"),
]
operations = [
migrations.AddField(
model_name="profile",
name="css_background",
field=models.TextField(
blank=True,
help_text='CSS custom background properties: url("image_url"), color, etc',
max_length=300,
null=True,
verbose_name="Custom background",
),
),
]

View file

@ -542,10 +542,7 @@ class Contest(models.Model, PageVotable, Bookmarkable):
return True
# If the user is a contest organizer or curator
if (
user.has_perm("judge.edit_own_contest")
and user.profile.id in self.editor_ids
):
if hasattr(user, "profile") and user.profile.id in self.editor_ids:
return True
return False
@ -775,12 +772,9 @@ class ContestProblem(models.Model):
partial = models.BooleanField(default=True, verbose_name=_("partial"))
is_pretested = models.BooleanField(default=False, verbose_name=_("is pretested"))
order = models.PositiveIntegerField(db_index=True, verbose_name=_("order"))
output_prefix_override = models.IntegerField(
help_text=_("0 to not show testcases, 1 to show"),
show_testcases = models.BooleanField(
verbose_name=_("visible testcases"),
null=True,
blank=True,
default=0,
default=False,
)
max_submissions = models.IntegerField(
help_text=_(

View file

@ -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(
@ -102,6 +109,13 @@ class Organization(models.Model):
"Organization membership test must be Profile or primany key"
)
def delete(self, *args, **kwargs):
contests = self.contest_set
for contest in contests.all():
if contest.organizations.count() == 1:
contest.delete()
super().delete(*args, **kwargs)
def __str__(self):
return self.name
@ -169,6 +183,7 @@ class Profile(models.Model):
("setter", "Problem Setter"),
("admin", "Admin"),
),
db_index=True,
)
mute = models.BooleanField(
verbose_name=_("comment mute"),
@ -229,6 +244,15 @@ 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)
email_change_pending = models.EmailField(blank=True, null=True)
css_background = models.TextField(
verbose_name=_("Custom background"),
null=True,
blank=True,
help_text=_('CSS custom background properties: url("image_url"), color, etc'),
max_length=300,
)
@cached_property
def organization(self):

View file

@ -1,4 +1,5 @@
from django.utils.timezone import now
from django.conf import settings
from judge.models import Profile
@ -17,8 +18,8 @@ class LogUserAccessMiddleware(object):
):
updates = {"last_access": now()}
# Decided on using REMOTE_ADDR as nginx will translate it to the external IP that hits it.
if request.META.get("REMOTE_ADDR"):
updates["ip"] = request.META.get("REMOTE_ADDR")
if request.META.get(settings.META_REMOTE_ADDRESS_KEY):
updates["ip"] = request.META.get(settings.META_REMOTE_ADDRESS_KEY)
Profile.objects.filter(user_id=request.user.pk).update(**updates)
return response

View file

@ -0,0 +1,20 @@
from django.template.loader import render_to_string
from django.contrib.sites.shortcuts import get_current_site
from django.conf import settings
def render_email_message(request, contexts):
current_site = get_current_site(request)
email_contexts = {
"username": request.user.username,
"domain": current_site.domain,
"protocol": "https" if request.is_secure() else "http",
"site_name": settings.SITE_NAME,
"message": None,
"title": None,
"button_text": "Click here",
"url_path": None,
}
email_contexts.update(contexts)
message = render_to_string("general_email.html", email_contexts)
return message

View file

@ -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,
),
)
)
@ -1340,9 +1357,7 @@ class ContestClarificationAjax(ContestMixin, DetailView):
raise Http404()
polling_time = 1 # minute
last_one_minute = last_five_minutes = timezone.now() - timezone.timedelta(
minutes=polling_time
)
last_one_minute = timezone.now() - timezone.timedelta(minutes=polling_time)
queryset = ContestProblemClarification.objects.filter(
problem__in=self.object.contest_problems.all(), date__gte=last_one_minute

121
judge/views/email.py Normal file
View file

@ -0,0 +1,121 @@
from django.contrib.auth.tokens import default_token_generator
from django.core.mail import send_mail
from django.shortcuts import render, redirect
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes, force_text
from django.conf import settings
from django import forms
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.contrib.auth.hashers import check_password
from urllib.parse import urlencode, urlunparse, urlparse
from judge.models import Profile
from judge.utils.email_render import render_email_message
class EmailChangeForm(forms.Form):
new_email = forms.EmailField(label=_("New Email"))
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)
def clean_new_email(self):
new_email = self.cleaned_data.get("new_email")
if User.objects.filter(email=new_email).exists():
raise forms.ValidationError(_("An account with this email already exists."))
return new_email
def clean_password(self):
password = self.cleaned_data.get("password")
if not self.user.check_password(password):
raise forms.ValidationError("Invalid password")
return password
@login_required
def email_change_view(request):
form = EmailChangeForm(request.POST or None, user=request.user)
if request.method == "POST" and form.is_valid():
new_email = request.POST.get("new_email")
user = request.user
profile = request.profile
# Generate a token for email verification
token = default_token_generator.make_token(user)
uid = urlsafe_base64_encode(force_bytes(user.pk))
# Send the email to the user
subject = settings.SITE_NAME + " - " + _("Email Change Request")
email_contexts = {
"message": _(
"We have received a request to change your email to this email. Click the button below to change your email:"
),
"title": _("Email Change"),
"button_text": _("Change Email"),
"url_path": reverse(
"email_change_verify", kwargs={"uidb64": uid, "token": token}
),
}
message = render_email_message(request, email_contexts)
send_mail(
subject,
message,
settings.EMAIL_HOST_USER,
[new_email],
html_message=message,
)
profile.email_change_pending = new_email
profile.save()
return redirect("email_change_pending")
return render(
request,
"email_change/email_change.html",
{
"form": form,
"title": _("Change email"),
},
)
def verify_email_view(request, uidb64, token):
try:
uid = force_text(urlsafe_base64_decode(uidb64))
user = User.objects.get(pk=uid)
except (TypeError, ValueError, OverflowError, User.DoesNotExist):
user = None
if user is not None and default_token_generator.check_token(user, token):
profile = Profile.objects.get(user=user)
new_email = profile.email_change_pending
if new_email and not User.objects.filter(email=new_email).exists():
user.email = new_email
profile.email_change_pending = None
user.save()
profile.save()
return render(
request,
"email_change/email_change_success.html",
{"title": _("Success"), "user": user},
)
return render(
request, "email_change/email_change_failure.html", {"title": _("Invalid")}
)
def email_change_pending_view(request):
return render(
request,
"email_change/email_change_pending.html",
{
"title": _("Email change pending"),
},
)

View file

@ -3,7 +3,7 @@ import json
from django.views.generic import ListView
from django.utils.translation import gettext as _, gettext_lazy
from django.db.models import Count
from django.db.models import Count, Q
from django.http import HttpResponseForbidden
from django.urls import reverse
@ -38,13 +38,19 @@ class InternalProblem(InternalView, ListView):
**kwargs
)
def get_search_query(self):
return self.request.GET.get("q") or self.request.POST.get("q")
def get_queryset(self):
queryset = (
Problem.objects.annotate(vote_count=Count("volunteer_user_votes"))
.filter(vote_count__gte=1)
.order_by("-vote_count")
)
return queryset
queryset = Problem.objects.annotate(
vote_count=Count("volunteer_user_votes")
).filter(vote_count__gte=1)
query = self.get_search_query()
if query:
queryset = queryset.filter(
Q(code__icontains=query) | Q(name__icontains=query)
)
return queryset.order_by("-vote_count")
def get_context_data(self, **kwargs):
context = super(InternalProblem, self).get_context_data(**kwargs)
@ -52,6 +58,7 @@ class InternalProblem(InternalView, ListView):
context["title"] = self.title
context["page_prefix"] = self.request.path + "?page="
context["first_page_href"] = self.request.path
context["query"] = self.get_search_query()
return context

View file

@ -926,8 +926,12 @@ class EditOrganizationContest(
super().post(request, *args, **kwargs)
return HttpResponseRedirect(
reverse(
"organization_contests",
args=(self.organization_id, self.organization.slug),
"organization_contest_edit",
args=(
self.organization_id,
self.organization.slug,
self.contest.key,
),
)
)

View file

@ -15,7 +15,7 @@ from registration.backends.default.views import (
from registration.forms import RegistrationForm
from sortedm2m.forms import SortedMultipleChoiceField
from judge.models import Language, Organization, Profile, TIMEZONE
from judge.models import Language, Profile, TIMEZONE
from judge.utils.recaptcha import ReCaptchaField, ReCaptchaWidget
from judge.widgets import Select2MultipleWidget, Select2Widget
@ -43,29 +43,10 @@ class CustomRegistrationForm(RegistrationForm):
empty_label=None,
widget=Select2Widget(attrs={"style": "width:100%"}),
)
organizations = SortedMultipleChoiceField(
queryset=Organization.objects.filter(is_open=True),
label=_("Groups"),
required=False,
widget=Select2MultipleWidget(attrs={"style": "width:100%"}),
)
if ReCaptchaField is not None:
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 groups.").format(
count=max_orgs
)
)
return self.cleaned_data["organizations"]
def clean_email(self):
if User.objects.filter(email=self.cleaned_data["email"]).exists():
raise forms.ValidationError(
@ -116,7 +97,6 @@ class RegistrationView(OldRegistrationView):
cleaned_data = form.cleaned_data
profile.timezone = cleaned_data["timezone"]
profile.language = cleaned_data["language"]
profile.organizations.add(*cleaned_data["organizations"])
profile.save()
return user

View file

@ -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": [
{
"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)
pk, username, email, display_rank, profile_image = user_tuple
return {
"text": username,
"id": encrypt_url(self.request.profile.id, pk),
"gravatar_url": gravatar(
None,
self.gravatar_size,
self.gravatar_default,
self.get_profile_image_url(profile_image),
email,
),
"display_rank": display_rank,
}

View file

@ -259,13 +259,13 @@ class SubmissionStatus(SubmissionDetailBase):
)
contest = submission.contest_or_none
prefix_length = 0
show_testcases = False
can_see_testcases = self.access_testcases_in_contest()
if contest is not None:
prefix_length = contest.problem.output_prefix_override or 0
show_testcases = contest.problem.show_testcases or False
if contest is None or prefix_length > 0 or can_see_testcases:
if contest is None or show_testcases or can_see_testcases:
context["cases_data"] = get_cases_data(submission)
context["can_see_testcases"] = True
try:

View file

@ -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()

View file

@ -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 *

View file

@ -16,8 +16,8 @@ class DateTimePickerWidget(forms.DateTimeInput):
@property
def media(self):
css_url = "https://cdnjs.cloudflare.com/ajax/libs/jquery-datetimepicker/2.5.20/jquery.datetimepicker.min.css"
js_url = "https://cdnjs.cloudflare.com/ajax/libs/jquery-datetimepicker/2.5.20/jquery.datetimepicker.full.min.js"
css_url = "/static/datetime-picker/datetimepicker.min.css"
js_url = "/static/datetime-picker/datetimepicker.full.min.js"
return forms.Media(
js=[js_url],
css={"screen": [css_url]},

16
judge/widgets/image.py Normal file
View 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

View file

@ -47,11 +47,6 @@ else:
"pagedown-extra/Markdown.Extra.js",
"pagedown_init.js",
]
css = {
"all": [
"markdown.css",
]
}
class AdminPagedownWidget(PagedownWidget, admin_widgets.AdminTextareaWidget):
class Media:
@ -60,7 +55,6 @@ else:
"pagedown_widget.css",
"content-description.css",
"admin/css/pagedown.css",
"markdown.css",
"pagedown.css",
]
}
@ -125,7 +119,6 @@ else:
"pygment-github.css",
"table.css",
"ranks.css",
"markdown.css",
"dmmd-preview.css",
]
}

File diff suppressed because it is too large Load diff

View file

@ -191,11 +191,19 @@ header {
display: block;
margin: 0;
& > li > a > span {
& > li > span {
height: 36px;
padding-top: 8px;
display: block;
white-space: nowrap;
cursor: pointer;
&:hover {
border-top: 2px solid #9c3706;
color: black;
background: rgba(255, 255, 255, 0.25);
margin: 0;
}
& > img {
vertical-align: middle;
@ -233,7 +241,7 @@ header {
top: 0;
left: 0;
right: 10px;
height: 48px;
height: $navbar_height;
}
nav {
@ -377,7 +385,7 @@ hr {
}
#content {
margin: 48px auto 1em auto;
margin: $navbar_height auto 1em auto;
padding-top: 1em;
// Header
@ -860,6 +868,16 @@ select {
margin-bottom: 1em;
}
#loading-bar {
position: fixed;
top: 0;
left: 0;
height: 2px;
background-color: #993932;
width: 0;
z-index: 9999;
}
@media (max-width: 799px) {
#user-links, .anon {
padding-right: 0.5em;

View file

@ -181,6 +181,7 @@
.left-sidebar-item {
display: flex;
align-items: center;
border-radius: .5em;
.sidebar-icon {
font-size: large;
@ -278,7 +279,6 @@
padding: 0.8em 0.2em 0.8em 0.2em;
display: inline-block;
flex: 1;
border-radius: 8px;
.sidebar-icon {
display: none;
@ -290,6 +290,7 @@
margin-bottom: 1em;
border-radius: 7px;
display: flex;
background: inherit;
}
.blog-box {
@ -305,9 +306,19 @@
@media (min-width: 800px) {
.left-sidebar-item {
margin-bottom: 10px;
margin-left: 10px;
border: 1px solid lightgray;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
background-color: white;
padding: 0.8em 0.2em 0.8em 1em;
}
.sidebar-text {
overflow: hidden;
text-overflow: ellipsis;
}
.middle-content,
.blog-sidebar,
.right-sidebar {
@ -345,9 +356,7 @@
height: 100%;
margin-top: -4em;
padding-top: 4em;
border-right: 1px solid lightgray;
box-shadow: 0px -10px 2px 0px rgb(0 0 0 / 50%);
background: white;
border-right: 1px;
}
.feed-table {

View file

@ -11,15 +11,6 @@
float: right;
margin-right: 1em;
}
#emoji-button {
position: absolute;
right: 1em;
font-size: 2em;
color: lightgray;
}
#emoji-button:hover {
color: gray;
}
#chat-log {
padding: 0;
padding-top: 2em;
@ -39,15 +30,15 @@
#chat-online {
border-right: 1px solid #ccc;
padding-bottom: 0 !important;
min-width: 25%;
border-bottom: 0;
font-size: 1.2em;
}
#chat-online-content {
margin-bottom: 0;
overflow: hidden;
overflow-wrap: break-word;
overflow-y: auto;
max-height: calc(100% - 44px);
max-height: 100%;
}
#chat-box {
/*border: 1px solid #ccc;*/
@ -58,37 +49,40 @@
overflow-y: scroll;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
height: 75%;
flex-grow: 1;
}
#chat-input {
width: 100%;
padding: 0.4em 4em 1em 1.2em;
border: 0;
color: black;
border-top-left-radius: 0;
border-top-right-radius: 0;
height: 100%;
font-size: 16px;
border: 2px solid #e4a81c;
}
#chat-online-content {
padding: 0;
width: 100%;
}
.selected-status-row {
background-color: lightgray;
}
.status_last_message {
color: darkgray;
font-size: 0.8em;
}
@media (min-width: 800px) {
#chat-container {
display: flex;
width: 100%;
height: calc(100vh - 3em);;
height: calc(100vh - 3em);
border: 1px solid #ccc;
/*border-radius: 0 4px 0 0;*/
border-bottom: 0;
}
#chat-online {
margin: 0;
width: 35%;
}
.chat-left-panel, .chat-right-panel {
display: block !important;
#chat-area {
flex-grow: 1;
}
}
#chat-input, #chat-log .content-message {
@ -97,14 +91,7 @@
.info-pic {
height: 100%;
}
.info-circle {
position: absolute;
cx: 12%;
cy: 12%;
r: 12%;
stroke: white;
stroke-width: 1;
}
.info-name {
margin-left: 10px;
font-size: 2em;
@ -138,29 +125,30 @@
transition: 1.5s ease-in-out;
}
.status-pic {
height: 1.3em;
width: 1.3em;
border-radius: 0.3em;
height: 32px;
width: 32px;
border-radius: 50%;
}
.status-container {
position: relative;
display: inline-flex;
flex: 0 0 auto;
}
.status-circle {
position: absolute;
bottom: 0;
right: 0;
cx: 18px;
cy: 18px;
cx: 27px;
cy: 27px;
r: 4.5px;
stroke: white;
stroke-width: 1;
}
.status-row {
display: flex;
font-size: 15px;
padding: 0.2em 0.2em 0.2em 1em;
border-radius: 4px;
padding: 15px;
gap: 0.5em;
border-radius: 6px;
}
.status-row:hover {
background: lightgray;
@ -168,6 +156,7 @@
}
.status-list {
padding: 0;
margin: 0;
}
.status-section-title {
cursor: pointer;
@ -178,6 +167,8 @@
border-radius: 15px;
max-width: 70%;
width: fit-content;
font-size: 1.05rem;
line-height: 1.3;
}
.message-text-other {
background: #eeeeee;
@ -187,7 +178,12 @@
background: rgb(0, 132, 255);
color: white;
}
.chat-input-icon {
color: #045343;
}
.chat-input-icon:hover {
background: lightgray;
}
.chat {
.active-span {
color: #636363;
@ -200,6 +196,7 @@
background-color: darkcyan;
border-radius: 2px;
padding: 0 0.5em;
align-self: flex-end;
}
#setting-content {
display: none;
@ -225,9 +222,6 @@
@media (max-width: 799px) {
#chat-area {
height: 500px;
}
#emoji-button {
display: none;
height: calc(100vh - 120px);
}
}

View file

@ -385,6 +385,13 @@ function onWindowReady() {
showTooltip(e.trigger, fallbackMessage(e.action));
});
});
$('a').click(function() {
$("#loading-bar").show();
$("#loading-bar").animate({ width: "100%" }, 1500, function() {
$(this).hide();
$("#loading-bar").css({ width: 0});
});
});
}
$(function() {
@ -429,4 +436,5 @@ $(function() {
$('html').click(function () {
$nav_list.hide();
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 542 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Before After
Before After

View file

@ -1,3 +1,4 @@
@import "vars";
.info-float {
position: sticky;
top: 60px;
@ -20,6 +21,11 @@
&:first-of-type {
border-top: #ccc 1px solid;
border-top-left-radius: $widget_border_radius;
border-top-right-radius: $widget_border_radius;
.sub-result {
border-top-left-radius: $widget_border_radius;
}
}
> div {

View file

@ -150,14 +150,6 @@
padding: 7px;
}
.message .content :first-child {
margin-top: 0;
}
.message .content :last-child {
margin-bottom: 0;
}
.new-message .detail {
padding: 8px 10px;
}
@ -174,4 +166,10 @@
padding-left: 0.5em;
padding-top: 1.65em;
}
}
@media (max-width: 799px) {
.ticket-container {
flex-direction: column-reverse;
}
}

View file

@ -10,3 +10,5 @@ $widget_border_radius: 4px;
$table_header_rounding: 6px;
$monospace-fonts: Consolas, "Andale Mono WT", "Andale Mono", "Lucida Console", "Lucida Sans Typewriter", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", "Liberation Mono", "Nimbus Mono L", Monaco, "Courier New", Courier, monospace;
$navbar_height: 48px;

View file

@ -277,6 +277,15 @@ input {
overflow-x: auto;
overflow-y: hidden;
&::-webkit-scrollbar {
width: 0.1rem; // Adjust the width as needed
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
> li {
position: relative;
display: block;
@ -614,6 +623,14 @@ ul.select2-selection__rendered {
background: gray;
}
ul.errorlist {
margin: 0px;
text-align: right;
list-style: none;
padding: 0px;
color: red;
}
.registration-form {
.sortedm2m-container, .sortedm2m-container p.selector-filter {
width: 300px;
@ -690,12 +707,6 @@ ul.select2-selection__rendered {
width: 450px;
}
ul.errorlist {
margin: 0px;
text-align: right;
list-style: none;
}
.full-textfield {
padding-top: 0.5em;
}

View file

@ -193,6 +193,15 @@
}
</style>
</noscript>
{% if request.profile.css_background %}
<style>
@media(min-width: 800px) {
#page-container {
background: {{request.profile.css_background|safe}};
}
}
</style>
{% endif %}
</head>
<body>
<svg width="0" height="0" style="display: block">
@ -202,7 +211,7 @@
</svg>
<nav id="navigation" class="unselectable">
<div id="nav-container">
<a id="navicon" href="javascript:void(0)"><i class="fa fa-bars"></i></a>
<span id="navicon"><i class="fa fa-bars"></i></span>
<ul id="nav-list">
<li class="home-nav-element"><a href="{{ url('home') }}">{% include "site-logo-fragment.html" %}</a></li>
<li class="home-nav-element"><span class="nav-divider"></span></li>
@ -261,34 +270,48 @@
</span>
{% if request.user.is_authenticated %}
<span id="user-links">
<ul><li><a href="javascript:void(0)">
<ul><li>
<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>
</span>
</a></li></ul>
</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 %}
<div class="dropdown-item">
<a href="#" id="logout" class="red">{{ _('Log out') }}</a>
<form id="logout-form" action="{{ url('auth_logout') }}" method="POST">
{% csrf_token %}
</form>
</div>
<a href="#" id="logout" class="red">
<div class="dropdown-item">
{{ _('Log out') }}
<form id="logout-form" action="{{ url('auth_logout') }}" method="POST">
{% csrf_token %}
</form>
</div>
</a>
{% endif %}
</div>
{% else %}
@ -302,6 +325,7 @@
</div>
<div id="nav-shadow"></div>
</nav>
<div id="loading-bar"></div>
{% if request.in_contest %}
<div id="contest-info">
<div id="contest-info-main">

View file

@ -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">

View file

@ -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 %}

View file

@ -7,21 +7,11 @@
<script type="text/javascript" src="{{ static('mathjax3_config.js') }}"></script>
<script type="text/javascript" src="{{ static('event.js') }}"></script>
<script src="https://unpkg.com/@popperjs/core@2"></script>
<script type="module" src="https://unpkg.com/emoji-picker-element@1"></script>
<script type="text/javascript">
let message_template = `
{% with message=message_template %}
{% include "chat/message.html" %}
{% endwith %}
`;
let META_HEADER = [
"{{_('Recent')}}",
"{{_('Following')}}",
"{{_('Admin')}}",
"{{_('Other')}}",
];
</script>
{% compress js %}
{% include "chat/chat_js.html" %}
{% endcompress %}
<script type="text/javascript">
window.limit_time = 24;
window.room_id = "{{room if room else ''}}";
@ -30,75 +20,11 @@
window.lock = false;
window.lock_click_space = false;
window.pushed_messages = new Set();
let isMobile = window.matchMedia("only screen and (max-width: 799px)").matches;
function load_next_page(last_id, refresh_html=false) {
var param = {
'last_id': last_id,
'only_messages': true,
}
$.get("{{ url('chat', '') }}" + window.room_id, param)
.fail(function() {
console.log("Fail to load page, last_id = " + last_id);
})
.done(function(data) {
if (refresh_html) {
$('#chat-log').html('');
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
window.lock = true;
}
var time = refresh_html ? 0 : 200;
setTimeout(function() {
$(".has_next").remove();
let $chat_box = $('#chat-box');
let lastMsgPos = scrollTopOfBottom($chat_box)
$('#loader').hide();
if (refresh_html) {
$('#chat-log').append(data);
}
else {
$('#chat-log').prepend(data);
}
register_time($('.time-with-rel'));
merge_authors();
if (!refresh_html) {
$chat_box.scrollTop(scrollTopOfBottom($chat_box) - lastMsgPos);
}
else {
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
}
window.lock = false;
window.has_next = parseInt($(".has_next").attr("value"));
}, time);
})
}
function scrollTopOfBottom(container) {
return container[0].scrollHeight - container.innerHeight()
}
function scrollContainer(container, loader) {
container.scroll(function() {
if (container.scrollTop() == 0) {
if (!window.lock && window.has_next) {
loader.show();
var message_ids = $('.message').map(function() {
return parseInt($(this).attr('message-id'));
}).get();
load_next_page(Math.min(...message_ids));
}
}
})}
window.load_dynamic_update = function (last_msg) {
var receiver = new EventReceiver(
"{{ EVENT_DAEMON_LOCATION }}", "{{ EVENT_DAEMON_POLL_LOCATION }}",
['chat_lobby', 'chat_{{request.profile.id}}'], last_msg, function (message) {
['{{chat_lobby_channel}}', '{{chat_channel}}'], last_msg, function (message) {
if (window.pushed_messages.has(message.message)) {
return;
}
@ -115,474 +41,13 @@
return receiver;
}
function refresh_status() {
$.get("{{url('online_status_ajax')}}")
.fail(function() {
console.log("Fail to get online status");
})
.done(function(data) {
if (data.status == 403) {
console.log("Fail to retrieve data");
}
else {
$('#chat-online-list').html(data).find('.toggle').each(function () {
register_toggle($(this));
});
register_click_space();
}
})
var data = {
'user': window.other_user_id,
};
$.get("{{url('user_online_status_ajax')}}", data)
.fail(function() {
console.log("Fail to get user online status");
})
.done(function(data) {
$('#chat-info').html(data);
register_time($('.time-with-rel'));
register_setting();
})
}
function add_message(data) {
var $data = $(data);
$('#chat-log').append($data);
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
register_time($('.time-with-rel'));
MathJax.typeset();
merge_authors();
}
function add_new_message(message, room, is_self_author) {
function callback(update) {
if (!document['hidden']) {
if (update) update_last_seen();
refresh_status();
}
else if (!is_self_author) {
window.unread_message++;
document.title = "(" + window.unread_message + ") " + "{{ _('New message(s)') }}";
}
}
if (room == window.room_id) {
$.get({
url: "{{ url('chat_message_ajax') }}",
data: {
message: message,
},
success: function (data) {
add_message(data);
callback(true);
},
error: function (data) {
console.log('Could not add new message');
}
});
}
else {
callback(false);
}
}
function check_new_message(message, tmp_id, room) {
if (room == room_id) {
$.get({
url: "{{ url('chat_message_ajax') }}",
data: {
message: message,
},
success: function (data) {
var $body_block = $(data).find('.body-block');
if ($('#message-'+tmp_id).length) {
$('#message-'+tmp_id).replaceWith(data);
}
else if ($('#body-block-'+tmp_id).length) {
$('#body-block-'+tmp_id).replaceWith($body_block);
}
else {
add_new_message(message, room, true);
}
MathJax.typeset();
register_time($('.time-with-rel'));
remove_unread_current_user();
merge_authors();
},
error: function (data) {
console.log('Fail to check message');
var $body = $('#body-block-'+tmp_id + ' p');
$body.css('text-decoration', 'line-through');
$body.css('text-decoration-color', 'red');
}
});
}
}
function merge_authors() {
var time_limit = 5; // minutes
var last = {
username: null,
time: null,
$content: null
};
$('.body-message').each(function() {
var username = $(this).find(".username a").text().trim();
var $body = $(this).find(".content-message .body-block");
var time = moment($(this).find(".time-with-rel").attr('data-iso'));
var $content = $(this).children('.content-message');
if (username == window.user.name) {
$(this).find('.message-text').each(function() {
$(this).removeClass('message-text-other').addClass('message-text-myself');
});
}
if (username == last.username && time.diff(last.time, 'minutes') <= time_limit) {
last.$content.append($body);
$(this).parent().remove();
}
else {
last.username = username;
last.time = time;
last.$content = $content;
}
});
}
function add_message_from_template(body, tmp_id) {
var html = message_template;
html = html.replaceAll('$body', body).replaceAll('$id', tmp_id);
var $html = $(html);
$html.find('.time-with-rel').attr('data-iso', (new Date()).toISOString());
add_message($html[0].outerHTML);
}
function submit_chat() {
{% if last_msg and not request.profile.mute %}
if ($("#chat-input").val().trim()) {
var body = $('#chat-input').val().trim();
// body = body.split('\n').join('\n\n');
var message = {
body: body,
room: window.room_id,
tmp_id: Date.now(),
};
$('#chat-input').val('');
add_message_from_template(body, message.tmp_id);
$.post("{{ url('post_chat_message') }}", message)
.fail(function(res) {
console.log('Fail to send message');
})
.done(function(res, status) {
$('#empty_msg').hide();
$('#chat-input').focus();
})
}
{% endif %}
}
function resize_emoji(element) {
var html = element.html();
html = html.replace(/(\p{Extended_Pictographic})/ug, `<span class="big-emoji">$1</span>`);
element.html(html);
}
function insert_char_after_cursor(elem, char) {
var val = elem.value;
if (typeof elem.selectionStart == "number" && typeof elem.selectionEnd == "number") {
var start = elem.selectionStart;
var prefix = elem.value.slice(0, start);
var prefix_added = prefix + char;
var chars = [...val];
chars.splice([...prefix].length, 0, char);
elem.value = chars.join('');
elem.selectionStart = elem.selectionEnd = prefix_added.length;
} else if (document.selection && document.selection.createRange) {
var range = document.selection.createRange();
elem.focus();
range.text = char;
range.collapse(false);
range.select();
}
}
function load_room(encrypted_user) {
if (window.lock_click_space) return;
function callback() {
history.replaceState(null, '', "{{url('chat', '')}}" + window.room_id);
load_next_page(null, true);
update_last_seen();
refresh_status();
$('#chat-input').focus();
}
window.lock_click_space = true;
if (encrypted_user) {
$.get("{{url('get_or_create_room')}}" + `?other=${encrypted_user}`)
.done(function(data) {
window.room_id = data.room;
window.other_user_id = data.other_user_id;
callback();
})
.fail(function() {
console.log('Fail to get_or_create_room');
})
}
else {
window.room_id = '';
window.other_user_id = '';
callback();
}
window.lock_click_space = false;
}
function register_click_space() {
$('.click_space').on('click', function(e) {
if ($(this).attr('id') == 'click_space_' + window.other_user_id) {
return;
}
var other_user = $(this).attr('value');
load_room(other_user);
});
$('#lobby_row').on('click', function(e) {
if (window.room_id) {
load_room(null);
}
});
if (isMobile) {
$('#chat-tab a').click();
}
}
function update_last_seen() {
var data = {
room: window.room_id
};
$.post("{{ url('update_last_seen') }}", data)
.fail(function(data) {
console.log('Fail to update last seen');
})
.done(function(data) {
})
}
function remove_unread_current_user() {
if (window.other_user_id) {
$("#unread-count-" + window.other_user_id).hide();
}
else {
$('#unread-count-lobby').hide();
}
}
function register_setting() {
$('#setting-button').on('click', function() {
$('#setting-content').toggle();
});
$('#setting-content li').on('click', function() {
$(this).children('a')[0].click();
})
$('#setting-content a').on('click', function() {
var href = $(this).attr('href');
href += '?next=' + window.location.pathname;
$(this).attr('href', href);
})
}
let message_template = `
{% with message=message_template %}
{% include "chat/message.html" %}
{% endwith %}
`;
$(function() {
$('#loader').hide();
merge_authors();
window.has_next = parseInt($(".has_next").attr("value"));
scrollContainer($('#chat-box'), $('#loader'))
{% if request.user.is_staff %}
$(document).on("click", ".chat_remove", function() {
var elt = $(this);
$.ajax({
url: "{{ url('delete_chat_message') }}",
type: 'post',
data: {
message: elt.attr('value'),
},
dataType: 'json',
success: function(data){
var $block = elt.parent();
if ($block.parent().find('.body-block').length > 1) {
$block.remove();
}
else {
elt.closest('li').remove();
}
},
fail: function(data) {
console.log('Fail to delete');
},
});
});
$(document).on("click", ".chat_mute", function() {
if (confirm("{{_('Mute this user and delete all messages?')}}")) {
var elt = $(this);
$.ajax({
url: "{{ url('mute_chat_message') }}",
type: 'post',
data: {
message: elt.attr('value'),
},
dataType: 'json',
success: function(data){
window.location.reload();
},
fail: function(data) {
console.log('Fail to delete');
},
});
}
});
{% endif %}
$("#chat-log").show();
$("#chat-log").change(function() {
$('#chat-log').scrollTop($('#chat-log')[0].scrollHeight);
});
$('#chat-input').focus();
$('#chat-input').keydown(function(e) {
if (e.keyCode === 13) {
if (e.ctrlKey || e.shiftKey) {
insert_char_after_cursor(this, "\n");
}
else {
e.preventDefault();
submit_chat();
}
return false
}
return true
});
$('.chat-right-panel').hide();
$('#chat-tab').find('a').click(function (e) {
e.preventDefault();
$('#chat-tab').addClass('active');
$('#online-tab').removeClass('active');
$('.chat-left-panel').show();
$('.chat-right-panel').hide();
});
$('#online-tab').find('a').click(function (e) {
e.preventDefault();
$('#online-tab').addClass('active');
$('#chat-tab').removeClass('active');
$('.chat-left-panel').hide();
$('.chat-right-panel').show();
});
$('#refresh-button').on('click', function(e) {
e.preventDefault();
refresh_status();
});
setInterval(refresh_status, 2 * 60 * 1000);
$('#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)
function toggleEmoji() {
tooltip.classList.toggle('shown')
}
$('#emoji-button').on('click', function(e) {
e.preventDefault();
toggleEmoji();
});
$('emoji-picker').on('emoji-click', function(e) {
var $chat = $('#chat-input').get(0);
insert_char_after_cursor($chat, e.detail.unicode);
$chat.focus();
})
register_click_space();
document.addEventListener('keydown', function(e) {
if (e.keyCode === 27 && $('.tooltip').hasClass('shown')) {
toggleEmoji();
}
})
$('#search-handle').replaceWith($('<select>').attr({
id: 'search-handle',
name: 'other',
onchange: 'form.submit()'
}));
var in_user_redirect = false;
$('#search-handle').select2({
placeholder: '{{ _('Search by handle...') }}',
ajax: {
url: '{{ url('chat_user_search_select2_ajax') }}'
},
minimumInputLength: 1,
escapeMarkup: function (markup) {
return markup;
},
templateResult: function (data, container) {
return $('<span>')
.append($('<img>', {
'class': 'user-search-image', src: data.gravatar_url,
width: 24, height: 24
}))
.append($('<span>', {'class': data.display_rank + ' user-search-name'}).text(data.text))
.append($('<a>', {href: '/user/' + data.text, 'class': 'user-redirect'})
.append($('<i>', {'class': 'fa fa-mail-forward'}))
.mouseover(function () {
in_user_redirect = true;
}).mouseout(function () {
in_user_redirect = false;
}));
}
}).on('select2:selecting', function () {
return !in_user_redirect;
});
$("#chat-input").on("keyup", function() {
$("#chat-input").scrollTop($("#chat-input")[0].scrollHeight);
});
// https://stackoverflow.com/questions/42121565/detecting-class-change-without-setinterval
if (typeof(MutationObserver) !== undefined) {
var observer = new MutationObserver(function (event) {
if (!document['hidden'] && window.unread_message > 0) {
update_last_seen();
refresh_status();
window.unread_message = 0;
document.title = "{{_('Chat Box')}}";
}
})
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class'],
childList: false,
characterData: false
})
}
register_setting();
});
</script>
@ -605,38 +70,36 @@
<div id="chat-container">
<div id="chat-online" class="chat-right-panel sidebox">
<h3>
<i class="fa fa-users"></i>{{_('Online Users')}}
</h3>
<div id="chat-online-content">
<div id="search-container">
<center>
<form id="search-form" name="form" action="{{ url('get_or_create_room') }}" method="post">
{% csrf_token %}
<input id="search-handle" type="text" name="search"
placeholder="{{ _('Search by handle...') }}">
</form>
</center>
<form id="chat-search-form" name="form" action="{{ url('get_or_create_room') }}" method="post">
{% csrf_token %}
<input id="search-handle" type="text" name="search"
placeholder="{{ _('Search by handle...') }}">
</form>
</div>
<div id="chat-online-list">
{% include "chat/online_status.html" %}
</div>
</div>
</div>
<div id="chat-area" class="chat-left-panel" style="width:100%">
<div id="chat-info" style="height: 10%">
<div id="chat-area" class="chat-left-panel">
<div id="chat-info">
{% include 'chat/user_online_status.html' %}
</div>
<div id="chat-box">
<img src="{{static('loading.gif')}}" id="loader">
<ul id="chat-log" style="display: none">
<ul id="chat-log">
{% include 'chat/message_list.html' %}
</ul>
</div>
<div style="height: 15%">
<a id="emoji-button" href="#" title="{{_('Emoji')}}"><i class="icofont-slightly-smile"></i></a>
<div id="chat-input-container">
<textarea maxlength="5000" id="chat-input" placeholder="{{_('Enter your message')}}"></textarea>
<div class="chat-input-icon" id="emoji-button" href="#" title="{{_('Emoji')}}"><i class="icofont-slightly-smile"></i>
</div>
<div class="chat-input-icon" id="submit-button">
<i class="fa fa-play"></i>
</div>
</div>
<div class="tooltip" role="tooltip">
<emoji-picker></emoji-picker>

View file

@ -1,133 +1,200 @@
<style>
footer {
display: none;
}
#content {
padding-top: 0;
}
::-webkit-scrollbar {
width: 20px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #d6dee1;
border-radius: 20px;
border: 6px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: #a8bbbf;
}
#page-container {
width: 100%;
}
.body-message img{
max-height: 12em;
}
.tooltip:not(.shown) {
display: none;
}
textarea {
resize: none;
}
.tooltip {
left: 120vh !important;
transform: translate(100px, 0) !important;
position: absolute;
}
#loader {
display: block;
margin-left: auto;
margin-right: auto;
width: 4%;
}
.profile-pic {
height: 2.6em;
width: 2.6em;
border-radius: 0.3em;
margin-top: 0.1em;
float: left;
}
.body-message {
padding-left: 3em;
padding-bottom: 0.5em;
border-bottom: 1px dotted lightgray;
}
.user-time {
margin-bottom: 0.3em;
}
.time {
margin-left: 0.5em;
}
.clear {
clear: both;
}
.content-message {
word-wrap: break-word;
}
.content-message p {
margin: 0;
}
#content {
width: 100%;
}
#content-body {
padding-bottom: 0;
}
#page-container {
min-height: 0;
}
.sidebox h3 {
border-radius: 0;
}
.body-block {
border-radius: 4px;
padding: 0.05em 0.6em;
width: 100%;
}
#search-form {
float: inherit;
}
#search-container {
margin-bottom: 0.4em;
}
#setting {
position: relative;
}
@media (min-width: 800px) {
#page-container {
position:fixed;
overflow:hidden;
}
}
@media (max-width: 799px) {
html, body {
max-width: 100%;
overflow-x: hidden;
}
#mobile ul {
width: 100%;
}
.info-pic {
margin-left: 0.5em;
}
.active-span {
{% compress css %}
<style>
footer {
display: none;
}
}
</style>
#content {
padding-top: 0;
}
::-webkit-scrollbar {
width: 20px;
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: #d6dee1;
border-radius: 20px;
border: 6px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: #a8bbbf;
}
#page-container {
width: 100%;
}
.body-message img{
max-height: 12em;
}
.tooltip:not(.shown) {
display: none;
}
textarea {
resize: none;
}
.tooltip {
position: absolute;
z-index: 1000;
}
#loader {
display: block;
margin-left: auto;
margin-right: auto;
width: 80px;
}
.profile-pic {
height: 2.6em;
width: 2.6em;
border-radius: 0.3em;
margin-top: 0.1em;
float: left;
}
.body-message {
padding-left: 3em;
padding-bottom: 0.5em;
}
.user-time {
margin-bottom: 0.3em;
}
.time {
margin-left: 0.5em;
}
.clear {
clear: both;
}
.content-message {
word-wrap: break-word;
}
.content-message p {
margin: 0;
}
#content {
width: 100%;
}
#content-body {
padding-bottom: 0;
}
#page-container {
min-height: 0;
}
.sidebox h3 {
border-radius: 0;
}
.body-block {
border-radius: 4px;
padding: 0.05em 0.6em;
width: 100%;
}
#search-form {
float: inherit;
}
#search-container {
padding: 1em;
}
#setting {
position: relative;
}
.status-user {
display: flex;
flex-direction: column;
min-width: 0;
flex-grow: 1;
}
.wrapline {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
#chat-input-container {
display: flex;
align-items: center;
gap: 1em;
}
#chat-input {
padding: 10px 20px;
font-size: 16px;
border-radius: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease-in-out;
width: 80%;
resize: none;
height: 100%;
}
#chat-input:focus {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
outline: none;
}
.chat-input-icon {
font-size: 16px;
border-radius: 50%;
width: 2em;
height: 2em;
display: flex;
justify-content: center;
align-items: center;
border: 2px solid;
}
.chat-input-icon:hover {
cursor: pointer;
}
#chat-area {
display: flex;
flex-direction: column;
}
.info-circle {
position: absolute;
cx: 5px;
cy: 5px;
r: 5px;
stroke: white;
stroke-width: 1;
}
.info-pic {
border-radius: 5px;
margin-left: 1em;
}
#chat-info {
height: 3em;
}
@media (min-width: 800px) {
#page-container {
position:fixed;
overflow:hidden;
}
#chat-input-container {
padding-left: 10%;
}
#chat-area {
padding-bottom: 1.5em;
}
}
@media (max-width: 799px) {
html, body {
max-width: 100%;
overflow-x: hidden;
}
#mobile ul {
width: 100%;
}
.info-pic {
margin-left: 0.5em;
}
.active-span {
display: none;
}
}
</style>
{% endcompress %}

568
templates/chat/chat_js.html Normal file
View file

@ -0,0 +1,568 @@
<script type="text/javascript">
let META_HEADER = [
"{{_('Recent')}}",
"{{_('Following')}}",
"{{_('Admin')}}",
"{{_('Other')}}",
];
let isMobile = window.matchMedia("only screen and (max-width: 799px)").matches;
function load_next_page(last_id, refresh_html=false) {
var param = {
'last_id': last_id,
'only_messages': true,
}
$.get("{{ url('chat', '') }}" + window.room_id, param)
.fail(function() {
console.log("Fail to load page, last_id = " + last_id);
})
.done(function(data) {
if (refresh_html) {
$('#chat-log').html('');
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
window.lock = true;
}
var time = refresh_html ? 0 : 200;
setTimeout(function() {
$(".has_next").remove();
let $chat_box = $('#chat-box');
let lastMsgPos = scrollTopOfBottom($chat_box)
$('#loader').hide();
if (refresh_html) {
$('#chat-log').append(data);
}
else {
$('#chat-log').prepend(data);
}
register_time($('.time-with-rel'));
merge_authors();
if (!refresh_html) {
$chat_box.scrollTop(scrollTopOfBottom($chat_box) - lastMsgPos);
}
else {
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
}
window.lock = false;
window.has_next = parseInt($(".has_next").attr("value"));
}, time);
})
}
function scrollTopOfBottom(container) {
return container[0].scrollHeight - container.innerHeight()
}
function scrollContainer(container, loader) {
container.scroll(function() {
if (container.scrollTop() == 0) {
if (!window.lock && window.has_next) {
loader.show();
var message_ids = $('.message').map(function() {
return parseInt($(this).attr('message-id'));
}).get();
load_next_page(Math.min(...message_ids));
}
}
})}
function refresh_status() {
$.get("{{url('online_status_ajax')}}")
.fail(function() {
console.log("Fail to get online status");
})
.done(function(data) {
if (data.status == 403) {
console.log("Fail to retrieve data");
}
else {
$('#chat-online-list').html(data).find('.toggle').each(function () {
register_toggle($(this));
});
register_click_space();
color_selected_room();
}
})
var data = {
'user': window.other_user_id,
};
$.get("{{url('user_online_status_ajax')}}", data)
.fail(function() {
console.log("Fail to get user online status");
})
.done(function(data) {
$('#chat-info').html(data);
register_time($('.time-with-rel'));
register_setting();
})
}
function add_message(data) {
var $data = $(data);
$('#chat-log').append($data);
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
register_time($('.time-with-rel'));
MathJax.typeset();
merge_authors();
}
function add_new_message(message, room, is_self_author) {
function callback(update) {
if (!document['hidden']) {
if (update) update_last_seen();
refresh_status();
}
else if (!is_self_author) {
window.unread_message++;
document.title = "(" + window.unread_message + ") " + "{{ _('New message(s)') }}";
}
}
if (room == window.room_id) {
$.get({
url: "{{ url('chat_message_ajax') }}",
data: {
message: message,
},
success: function (data) {
add_message(data);
callback(true);
},
error: function (data) {
console.log('Could not add new message');
}
});
}
else {
callback(false);
}
}
function check_new_message(message, tmp_id, room) {
if (room == room_id) {
$.get({
url: "{{ url('chat_message_ajax') }}",
data: {
message: message,
},
success: function (data) {
var $body_block = $(data).find('.body-block');
if ($('#message-'+tmp_id).length) {
$('#message-'+tmp_id).replaceWith(data);
}
else if ($('#body-block-'+tmp_id).length) {
$('#body-block-'+tmp_id).replaceWith($body_block);
}
else {
add_new_message(message, room, true);
}
MathJax.typeset();
register_time($('.time-with-rel'));
remove_unread_current_user();
merge_authors();
},
error: function (data) {
console.log('Fail to check message');
var $body = $('#body-block-'+tmp_id + ' p');
$body.css('text-decoration', 'line-through');
$body.css('text-decoration-color', 'red');
}
});
}
}
function merge_authors() {
var time_limit = 5; // minutes
var last = {
username: null,
time: null,
$content: null
};
$('.body-message').each(function() {
var username = $(this).find(".username a").text().trim();
var $body = $(this).find(".content-message .body-block");
var time = moment($(this).find(".time-with-rel").attr('data-iso'));
var $content = $(this).children('.content-message');
if (username == window.user.name) {
$(this).find('.message-text').each(function() {
$(this).removeClass('message-text-other').addClass('message-text-myself');
});
}
if (username == last.username && time.diff(last.time, 'minutes') <= time_limit) {
last.$content.append($body);
$(this).parent().remove();
}
else {
last.username = username;
last.time = time;
last.$content = $content;
}
});
}
function add_message_from_template(body, tmp_id) {
if (window.room_id) {
$("#last_msg-" + window.room_id).html(body);
}
var html = message_template;
html = html.replaceAll('$body', body).replaceAll('$id', tmp_id);
var $html = $(html);
$html.find('.time-with-rel').attr('data-iso', (new Date()).toISOString());
add_message($html[0].outerHTML);
}
function submit_chat() {
{% if last_msg and not request.profile.mute %}
if ($("#chat-input").val().trim()) {
$('#chat-input-container').height('auto');
var body = $('#chat-input').val().trim();
// body = body.split('\n').join('\n\n');
var message = {
body: body,
room: window.room_id,
tmp_id: Date.now(),
};
$('#chat-input').val('');
add_message_from_template(body, message.tmp_id);
$.post("{{ url('post_chat_message') }}", message)
.fail(function(res) {
console.log('Fail to send message');
})
.done(function(res, status) {
$('#empty_msg').hide();
$('#chat-input').focus();
})
}
{% endif %}
}
function resize_emoji(element) {
var html = element.html();
html = html.replace(/(\p{Extended_Pictographic})/ug, `<span class="big-emoji">$1</span>`);
element.html(html);
}
function insert_char_after_cursor(elem, char) {
var val = elem.value;
if (typeof elem.selectionStart == "number" && typeof elem.selectionEnd == "number") {
var start = elem.selectionStart;
var prefix = elem.value.slice(0, start);
var prefix_added = prefix + char;
var chars = [...val];
chars.splice([...prefix].length, 0, char);
elem.value = chars.join('');
elem.selectionStart = elem.selectionEnd = prefix_added.length;
} else if (document.selection && document.selection.createRange) {
var range = document.selection.createRange();
elem.focus();
range.text = char;
range.collapse(false);
range.select();
}
}
function color_selected_room() {
$(".status-row").removeClass("selected-status-row");
$("#click_space_" + window.other_user_id).addClass("selected-status-row");
}
function load_room(encrypted_user) {
if (window.lock_click_space) return;
function callback() {
history.replaceState(null, '', "{{url('chat', '')}}" + window.room_id);
load_next_page(null, true);
update_last_seen();
refresh_status();
$('#chat-input').focus();
}
window.lock_click_space = true;
if (encrypted_user) {
$.get("{{url('get_or_create_room')}}" + `?other=${encrypted_user}`)
.done(function(data) {
window.room_id = data.room;
window.other_user_id = data.other_user_id;
color_selected_room();
callback();
})
.fail(function() {
console.log('Fail to get_or_create_room');
})
}
else {
window.room_id = '';
window.other_user_id = '';
color_selected_room();
callback();
}
window.lock_click_space = false;
}
function register_click_space() {
$('.click_space').on('click', function(e) {
if ($(this).attr('id') == 'click_space_' + window.other_user_id) {
return;
}
var other_user = $(this).attr('value');
load_room(other_user);
});
$('#lobby_row').on('click', function(e) {
if (window.room_id) {
load_room(null);
}
});
if (isMobile) {
$('#chat-tab a').click();
}
}
function update_last_seen() {
var data = {
room: window.room_id
};
$.post("{{ url('update_last_seen') }}", data)
.fail(function(data) {
console.log('Fail to update last seen');
})
.done(function(data) {
})
}
function remove_unread_current_user() {
if (window.other_user_id) {
$("#unread-count-" + window.other_user_id).hide();
}
else {
$('#unread-count-lobby').hide();
}
}
function register_setting() {
$('#setting-button').on('click', function() {
$('#setting-content').toggle();
});
$('#setting-content li').on('click', function() {
$(this).children('a')[0].click();
})
$('#setting-content a').on('click', function() {
var href = $(this).attr('href');
href += '?next=' + window.location.pathname;
$(this).attr('href', href);
})
}
$(function() {
$('#loader').hide();
update_last_seen();
merge_authors();
window.has_next = parseInt($(".has_next").attr("value"));
scrollContainer($('#chat-box'), $('#loader'))
{% if request.user.is_staff %}
$(document).on("click", ".chat_remove", function() {
var elt = $(this);
$.ajax({
url: "{{ url('delete_chat_message') }}",
type: 'post',
data: {
message: elt.attr('value'),
},
dataType: 'json',
success: function(data){
var $block = elt.parent();
if ($block.parent().find('.body-block').length > 1) {
$block.remove();
}
else {
elt.closest('li').remove();
}
},
fail: function(data) {
console.log('Fail to delete');
},
});
});
$(document).on("click", ".chat_mute", function() {
if (confirm("{{_('Mute this user and delete all messages?')}}")) {
var elt = $(this);
$.ajax({
url: "{{ url('mute_chat_message') }}",
type: 'post',
data: {
message: elt.attr('value'),
},
dataType: 'json',
success: function(data){
window.location.reload();
},
fail: function(data) {
console.log('Fail to delete');
},
});
}
});
{% endif %}
$("#chat-log").show();
$("#chat-log").change(function() {
$('#chat-log').scrollTop($('#chat-log')[0].scrollHeight);
});
$('#chat-input').focus();
$('#chat-input').keydown(function(e) {
if (e.keyCode === 13) {
if (e.ctrlKey || e.shiftKey) {
insert_char_after_cursor(this, "\n");
}
else {
e.preventDefault();
submit_chat();
}
return false
}
return true
});
$('#chat-tab').find('a').click(function (e) {
e.preventDefault();
$('#chat-tab').addClass('active');
$('#online-tab').removeClass('active');
$('.chat-left-panel').show();
$('.chat-right-panel').hide();
});
$('#online-tab').find('a').click(function (e) {
e.preventDefault();
$('#online-tab').addClass('active');
$('#chat-tab').removeClass('active');
$('.chat-left-panel').hide();
$('.chat-right-panel').show();
});
$('#refresh-button').on('click', function(e) {
e.preventDefault();
refresh_status();
});
setInterval(refresh_status, 2 * 60 * 1000);
$('#chat-box').scrollTop($('#chat-box')[0].scrollHeight);
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);
$chat.focus();
})
register_click_space();
document.addEventListener('keydown', function(e) {
if (e.keyCode === 27 && $('.tooltip').hasClass('shown')) {
toggleEmoji();
}
})
$('#search-handle').replaceWith($('<select>').attr({
id: 'search-handle',
name: 'other',
onchange: 'form.submit()'
}));
var in_user_redirect = false;
$('#search-handle').select2({
placeholder: '<i class="fa fa-search"></i> {{ _('Search by handle...') }}',
ajax: {
url: '{{ url('chat_user_search_select2_ajax') }}'
},
minimumInputLength: 1,
escapeMarkup: function (markup) {
return markup;
},
templateResult: function (data, container) {
return $('<span>')
.append($('<img>', {
'class': 'user-search-image', src: data.gravatar_url,
width: 24, height: 24
}))
.append($('<span>', {'class': data.display_rank + ' user-search-name'}).text(data.text))
.append($('<a>', {href: '/user/' + data.text, 'class': 'user-redirect'})
.append($('<i>', {'class': 'fa fa-mail-forward'}))
.mouseover(function () {
in_user_redirect = true;
}).mouseout(function () {
in_user_redirect = false;
}));
}
}).on('select2:selecting', function () {
return !in_user_redirect;
});
$("#chat-input").on("keyup", function() {
$("#chat-input").scrollTop($("#chat-input")[0].scrollHeight);
});
// https://stackoverflow.com/questions/42121565/detecting-class-change-without-setinterval
if (typeof(MutationObserver) !== undefined) {
var observer = new MutationObserver(function (event) {
if (!document['hidden'] && window.unread_message > 0) {
update_last_seen();
refresh_status();
window.unread_message = 0;
document.title = "{{_('Chat Box')}}";
}
})
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class'],
childList: false,
characterData: false
})
}
register_setting();
color_selected_room();
$('#chat-input').on('input', function () {
$('#chat-input-container').height('auto');
$('#chat-input-container').height((this.scrollHeight) + 'px');
});
$('#submit-button').on('click', submit_chat);
});
</script>

View file

@ -27,11 +27,18 @@
fill="{{'green' if user.is_online else 'red'}}"/>
</svg>
</div>
<span style="padding-left:0.3em" class="username {{ user.user.css_class }}">
{{ user.user.username }}
</span>
<span class="spacer">
<span class="unread-count" id="unread-count-{{user.user.id}}">{{user.unread_count if user.unread_count}}</span>
<div class="status-user">
<span class="username {{ user.user.css_class }} wrapline">
{{ user.user.username }}
</span>
{% if user.last_msg %}
<span class="status_last_message wrapline" {% if user.room %}id="last_msg-{{user.room}}"{% endif %}>
{{ user.last_msg }}
</span>
{% endif %}
</div>
<span class="unread-count" id="unread-count-{{user.user.id}}">
{{user.unread_count if user.unread_count}}
</span>
</li>
{% endfor %}

View file

@ -1,7 +1,7 @@
{% if other_user %}
<div class="status-container" style="height: 100%">
<img src="{{ gravatar(other_user.user, 135) }}" class="info-pic">
<svg style="position:absolute; height:100%; width: 100%">
<img src="{{ gravatar(other_user, 135) }}" class="info-pic">
<svg style="position:absolute; height:100%; width: 100%; transform: rotate(180deg);" >
<circle class="info-circle"
fill="{{'green' if other_online else 'red'}}"/>
</svg>

View file

@ -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>

View file

@ -278,11 +278,11 @@
<br>
{% endif %}
<h3 class="toggle open contest-group-header">
<i class="fa fa-chevron-right fa-fw"></i>
{{ _('Ongoing Contests') }}
</h3>
{% if current_contests %}
<h3 class="toggle open contest-group-header">
<i class="fa fa-chevron-right fa-fw"></i>
{{ _('Ongoing Contests') }}
</h3>
<div id="ongoing-table" class="toggled">
<table class="contest-list table striped">
<thead>
@ -319,6 +319,11 @@
</table>
<br>
</div>
{% else %}
<div class="toggled">
<i> {{ _('There is no ongoing contest at this time.') }} </i>
<br>
</div>
{% endif %}
<h3 class="toggle open contest-group-header">
@ -354,15 +359,17 @@
</table>
</div>
{% else %}
<i>{{ _('There are no scheduled contests at this time.') }}</i>
<br>
<div class="toggled">
<i>{{ _('There is no scheduled contest at this time.') }}</i>
<br>
</div>
{% endif %}
<br>
<h3 class="toggle open contest-group-header">
{{ _('Past Contests') }}
</h3>
{% if past_contests %}
<h3 class="toggle open contest-group-header">
{{ _('Past Contests') }}
</h3>
{% if page_obj and page_obj.num_pages > 1 %}
<div style="margin-bottom: 4px;">
{% include "list-pages.html" %}
@ -411,6 +418,11 @@
{% include "list-pages.html" %}
</div>
{% endif %}
{% else %}
<div class="toggled">
<i> {{ _('There is no past contest.') }} </i>
<br>
</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block media %}
<style type="text/css">
.errorlist {
font-size: 0.8em;
}
</style>
{% endblock %}
{% block body %}
<form action="" method="post" class="form-area">{% csrf_token %}
<table border="0">{{ form.as_table() }}</table>
<hr>
<button style="float:right;" type="submit">{{ _('Verify Email') }}</button>
</form>
{% endblock %}

View file

@ -0,0 +1,4 @@
{% extends "base.html" %}
{% block body %}
<p>{{ _('Invalid reset link.') }}</p>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block body %}
{% if request.profile.email_change_pending %}
<p>{{ _('An email was sent to') }} {{request.profile.email_change_pending}}. {{_('If you don\'t see it, kindly check your spam folder as well.')}}</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,4 @@
{% extends "base.html" %}
{% block body %}
<p>{{ _('Your email was sucessfully changed to') }} {{user.email}}</p>
{% endblock %}

View file

@ -0,0 +1,21 @@
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
background-color: #f7f7f7;
}
</style>
<div style="max-width: 600px; margin: 0 auto; padding: 20px; background-color: #ffffff; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);">
<div style="text-align: center; margin-bottom: 20px;">
<img src="{{ protocol }}://{{ domain }}{{static('icons/logo.png')}}" alt="{{site_name}}" style="max-width: 150px;">
</div>
<div style="margin-bottom: 20px; text-align: center;">
<h2>{{title}}</h2>
<p>{{_('Dear')}} {{username}},</p>
<p>{{message}}</p>
</div>
<div style="text-align: center;">
<a href="{{ protocol }}://{{ domain }}{{ url_path }}" style="display: inline-block; padding: 10px 20px; background-color: #007bff; color: #ffffff; text-decoration: none; border-radius: 4px;">{{button_text}}</a>
</div>
</div>

View file

@ -29,6 +29,11 @@
{% endblock %}
{% block middle_content %}
<form>
<label for="name">{{_('Search')}}:</label>
<input type="text" name="q" value="{{query}}">
</form>
<br>
<table class="table">
<thead>
<tr>

View file

@ -17,7 +17,7 @@
display: inline-flex;
}
.problems-problem {
width: 40%;
max-width: 60vh;
}
input[type=number] {
width: 5em;
@ -34,11 +34,10 @@
{% block middle_content %}
<form action="" method="post">
{% csrf_token %}
{% if form.errors %}
{% if form.errors or problems_form.errors %}
<div class="alert alert-danger alert-dismissable">
<a href="#" class="close">x</a>
{{ form.non_field_errors() }}
{{ form.errors }}
{{_("Please fix below errors")}}
</div>
{% endif %}
{% for field in form %}
@ -64,7 +63,7 @@
<tr>
{% for field in problems_form[0] %}
{% if not field.is_hidden %}
<th>
<th class="problems-{{field.name}}">
{{field.label}}
</th>
{% endif %}

View file

@ -3,8 +3,7 @@
{% if form.errors %}
<div class="alert alert-danger alert-dismissable">
<a href="#" class="close">x</a>
{{ form.non_field_errors() }}
{{ form.errors }}
{{ _("Please fix below errors") }}
</div>
{% endif %}
{% for field in form %}

View file

@ -6,7 +6,7 @@
{% if show_preview %}
<div id="{{ postfix|safe }}-preview" data-preview-url="{{ preview_url }}" data-textarea-id="wmd-input-{{ postfix }}"
data-timeout="{{ preview_timeout or '' }}" class="wmd-panel wmd-preview dmmd-preview {{ extra_classes }}">
<div class="dmmd-preview-update"><i class="fa fa-refresh"></i> Update Preview</div>
<div class="dmmd-preview-update"><i class="fa fa-refresh"></i> {{_('Update Preview')}}</div>
<div class="dmmd-preview-content content-description"></div>
</div>
{% endif %}

View file

@ -166,12 +166,12 @@
$checker.change(function () {
$tr_checker.toggle($checker.val() == 'custom').change();
$tr_validator.toggle($checker.val() == 'customval' || $checker.val() == 'testlib').change();
$tr_interactive.toggle($checker.val() == 'interact').change();
$sample.toggle(['custom', 'customval', 'interact'].includes($checker.val())).change();
}).change();
$ioi_signature.change(function() {
$tr_interactive.toggle($ioi_signature.is(':checked')).change();
$tr_sig_header.toggle($ioi_signature.is(':checked')).change();
$tr_sig_handler.toggle($ioi_signature.is(':checked')).change();
}).change();

View file

@ -1,23 +1,16 @@
<b>Thanks for registering on the {{ site.name }}! We're glad to have you.</b>
<br><br>
The last step is activating your account. Please activate your {{ SITE_NAME }} account in the next {{ expiration_days }} days.
<br><br>
Please click on the following link to activate your account:
<p style="margin-left:1em">
<a href="http://{{ site.domain }}/accounts/activate/{{ activation_key }}/">http://{{ site.domain }}/accounts/activate/{{ activation_key }}</a>
</p>
Alternatively, you can reply to this message to activate your account.
Your reply must keep the following text intact for this to work:
{% set url_path = "/accounts/activate/" + activation_key %}
{% set title = _("Account activation") %}
{% set message = _("Thanks for registering! We're glad to have you. The last step is activating your account. Please activate your account in the next %(expiration_days)d days.", expiration_days=expiration_days) %}
{% set username = user.get_username() %}
{% set button_text = _("Activate") %}
{% set domain = site.domain %}
{% set protocol = "http" %}
{% include "general_email.html" %}
<br>
{{_("Alternatively, you can reply to this message to activate your account. Your reply must keep the following text intact for this to work:")}}
<pre style="margin-left:1em">
{{ activation_key }}
</pre>
{% if SITE_ADMIN_EMAIL %}
See you soon!
<br>
If you have problems activating your account, feel free to send us an email at <a href="mailto:{{ SITE_ADMIN_EMAIL }}">{{ SITE_ADMIN_EMAIL }}</a>.
{% else %}
See you soon!
{% endif %}
{{_("See you soon!")}}

View file

@ -1 +1 @@
Activate your {{ SITE_NAME }} account
{% trans %}Activate your {{ SITE_NAME }} account{% endtrans %}

View file

@ -1,19 +1,6 @@
<span style="display:block;margin: 0 2px;padding: 1em;border: 3px solid #FFDE05;background-color: #000;border-radius: 6px;font-size: .95em;color: #444;margin-bottom:0.75em">
<div style="display:table;margin-left:2em"><img src="https://avatars1.githubusercontent.com/u/6934864?v=3&s=101" style="display:inline;vertical-align: middle">
<h1 style="font-size:4em;display:inline;vertical-align: middle"><a href="//{{ domain }}" style="text-decoration:none;color:gray"><span style="color: #FFDE05">LQD</span>OJ</a>
</h1>
</div>
</span>
<div style="display:block;margin: 0 2px;padding: 1em;border: 3px solid #2980B9;background-color: #f8f8f8;border-radius: 6px;font-size: .95em;color: #444;">
<b>Forgot your password on the {{ site_name }}? Don't worry!</b><br><br>
To reset the password for your account "{{ user.get_username() }}", click the below button.
<p align="center">
<a href="{{ protocol }}://{{ domain }}{{ url('password_reset_confirm', uidb64=uid, token=token) }}" style="cursor: pointer;display:block;text-align: center;padding: 4px 2px 5px;color: white;border: 1px solid #666;border-radius: 1px;background: #2980b9;background: linear-gradient(180deg, #00aee0, #2980b9);text-decoration: none;line-height:2em;font-size:1emm;width:12em;">Reset password</a>
</p>
{% if SITE_ADMIN_EMAIL %}
See you soon! If you have problems resetting your email, feel free to shoot us an email at <a href="mailto:{{ SITE_ADMIN_EMAIL }}">{{ SITE_ADMIN_EMAIL }}</a>
{% else %}
See you soon!
{% endif %}
</div>
{% set url_path = url('password_reset_confirm', uidb64=uid, token=token) %}
{% set title = _("Password Reset") %}
{% set message = _("We have received a request to reset your password. Click the button below to reset your password:") %}
{% set username = user.get_username() %}
{% set button_text = _("Reset Password") %}
{% include "general_email.html" %}

View file

@ -1,4 +1,4 @@
{% extends "base.html" %}
{% block body %}
<p>{{ _('You have successfully been registered. An email has been sent to the email address you provided to confirm your registration.') }}</p>
<p>{{ _('You have successfully been registered. An email has been sent to the email address you provided to confirm your registration. If you don\'t see it, kindly check your spam folder as well.') }}</p>
{% endblock %}

View file

@ -78,12 +78,6 @@
<div class="block-header">{{ _('Default language') }}</div>
<span class="fullwidth">{{ form.language }}</span>
<div class="block-header">{{ _('Affiliated organizations') }}</div>
{{ form.organizations }}
{% if form.organizations.errors %}
<div class="form-field-error">{{ form.organizations.errors }}</div>
{% endif %}
{% if form.captcha %}
<div style="margin-top: 0.5em">{{ form.captcha }}</div>
{% if form.captcha.errors %}
@ -92,12 +86,6 @@
{% endif %}
<hr>
{% if tos_url %}
<span class="tos-section">
{{ _('By registering, you agree to our') }}
<a href="{{ tos_url }}">{{ _('Terms & Conditions') }}</a>.
</span>
{% endif %}
<button style="float:right;" type="submit">{{ _('Register!') }}</button>
</form>
</div>

View file

@ -1,8 +1,3 @@
{% if submission.contest_or_none %}
{% set prefix_length = submission.contest_or_none.problem.output_prefix_override %}
{% else %}
{% set prefix_length = None %}
{% endif %}
{% set is_pretest = submission.is_pretested %}
{% if submission.status != 'IE' %}

View file

@ -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 %}

View file

@ -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>

View file

@ -43,9 +43,13 @@
#center-float {
position: relative;
margin: 0 auto auto -28.5em;
left: 60%;
width: 700px;
width: 100%;
display: flex;
justify-content: center;
}
.main-info tr td {
padding-bottom: 1em;
}
</style>
{% endblock %}
@ -79,17 +83,19 @@
{% 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 %}
{% csrf_token %}
<table class="block-header grayed">
<table class="block-header grayed main-info">
<tr>
<td> {{ _('Fullname') }}: </td>
<td> {{ form_user.first_name }} </td>
@ -98,6 +104,27 @@
<td> {{ _('School') }}: </td>
<td> {{ form_user.last_name }} </td>
</tr>
<tr>
<td> {{ _('Password') }}: </td>
<td>
<a href="{{ url('password_change') }}">
{{ _('Change your password') }}
</a>
</td>
</tr>
<tr>
<td> {{ _('Email') }}: </td>
<td>
{{ request.user.email }}
<a href="{{ url('email_change') }}">
({{ _('Change email') }})
</a>
</td>
</tr>
<tr>
<td>{{ _('Avatar') }}: </td>
<td>{{ form.profile_image }}</td>
</tr>
</table>
<hr>
@ -127,38 +154,29 @@
<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">
{{ _('Change your password') }}
</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
<br>
<div>
{% if profile.is_totp_enabled %}
{{ _('Two Factor Authentication is enabled.') }}
{% if require_staff_2fa and request.user.is_staff %}
<a id="disable-2fa-button" class="button inline-button">Disable</a>
{% else %}
<a href="{{ url('disable_2fa') }}" class="button inline-button">Disable</a>
<a href="{{ url('disable_2fa') }}" class="button inline-button">{{_('Disable')}}</a>
{% endif %}
{% else %}
{{ _('Two Factor Authentication is disabled.') }}
<a href="{{ url('enable_2fa') }}" class="button inline-button">Enable</a>
<a href="{{ url('enable_2fa') }}" class="button inline-button">{{_('Enable')}}</a>
{% endif %}
</div>
<br><hr>
<span class="block-header">{{ _('CSS background') }}:</span>
{{ form.css_background }}
<div><i>{{form.css_background.help_text}}</i></div>
<br><br>
<div class="block-header">{{ _('User-script') }}:</div>
{{ form.user_script }}
<hr>

View file

@ -59,16 +59,6 @@
{{user.user.first_name}}{% if user.user.last_name %} ({{user.user.last_name}}){% endif %}
</p>
{% endif %}
{% with orgs=user.organizations.all() %}
{% if orgs %}
<p style="margin-top: 0"><i class="fa fa-university"></i> {{ _('From') }}
{% for org in orgs -%}
<a href="{{ org.get_absolute_url() }}">{{ org.name }}</a>
{%- if not loop.last %}, {% endif %}
{% endfor %}
</p>
{% endif %}
{% endwith %}
{% if perms.judge.change_profile %}
{% with notes=user.notes %}
{% if notes %}
@ -78,7 +68,6 @@
{% endif %}
{% endwith %}
{% endif%}
{% if user.about %}
<h4>{{ _('About') }}</h4>
{% cache 86400 'user_about' user.id MATH_ENGINE %}

View 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
View 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);
}