diff --git a/dmoj/urls.py b/dmoj/urls.py index 7d16871fc..e1f9ddfe3 100644 --- a/dmoj/urls.py +++ b/dmoj/urls.py @@ -229,6 +229,7 @@ def paged_list_view(view, name): path('contests.ics', contests.ContestICal.as_view(), name='contest_ical'), path('contests///', contests.ContestCalendar.as_view(), name='contest_calendar'), path('contests/new', contests.CreateContest.as_view(), name='contest_new'), + path('contests/reorder/', contests.ContestReorder.as_view(), name='contest_reorder'), re_path(r'^contests/tag/(?P[a-z-]+)', include([ path('', paged_list_view(contests.ContestTagList, 'contest_tag_list')), ])), diff --git a/judge/models/contest.py b/judge/models/contest.py index 4ecfeed4d..529e94eab 100644 --- a/judge/models/contest.py +++ b/judge/models/contest.py @@ -77,6 +77,7 @@ class Contest(models.Model): (SCOREBOARD_AFTER_CONTEST, _('Hidden for duration of contest')), (SCOREBOARD_AFTER_PARTICIPATION, _('Hidden for duration of participation')), ) + sort_order = models.IntegerField(verbose_name=_('sort order'), default=0, db_index=True) key = models.CharField(max_length=32, verbose_name=_('contest id'), unique=True, validators=[RegexValidator('^[a-z0-9_]+$', _('Contest id must be ^[a-z0-9_]+$'))]) name = models.CharField(max_length=100, verbose_name=_('contest name'), db_index=True) @@ -553,6 +554,7 @@ class Meta: ) verbose_name = _('contest') verbose_name_plural = _('contests') + ordering = ('sort_order', 'key') class ContestAnnouncement(models.Model): diff --git a/judge/views/contests.py b/judge/views/contests.py index f8a8707be..b1a971581 100644 --- a/judge/views/contests.py +++ b/judge/views/contests.py @@ -9,6 +9,7 @@ from bs4 import BeautifulSoup from django import forms from django.conf import settings +from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth.context_processors import PermWrapper from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.models import User as UserModel @@ -24,6 +25,7 @@ from django.template.loader import get_template from django.urls import reverse from django.utils import timezone +from django.utils.decorators import method_decorator from django.utils.functional import cached_property from django.utils.html import escape, format_html from django.utils.safestring import mark_safe @@ -114,7 +116,7 @@ def _get_queryset(self): def get_queryset(self): self.search_query = None - query_set = self._get_queryset().order_by(self.order, 'key').filter(end_time__lt=self._now) + query_set = self._get_queryset().order_by('sort_order', self.order, 'key').filter(end_time__lt=self._now) if 'search' in self.request.GET: self.search_query = search_query = ' '.join(self.request.GET.getlist('search')).strip() if search_query: @@ -147,9 +149,13 @@ def get_context_data(self, **kwargs): active.append(participation) present.remove(participation.contest) - active.sort(key=attrgetter('end_time', 'key')) - present.sort(key=attrgetter('end_time', 'key')) - future.sort(key=attrgetter('start_time')) + active.sort(key=lambda p: (p.contest.sort_order, p.contest.key)) + present.sort(key=lambda c: (c.sort_order, c.key)) + future.sort(key=lambda c: (c.sort_order, c.key)) + + # active.sort(key=attrgetter('end_time', 'key')) + # present.sort(key=attrgetter('end_time', 'key')) + # future.sort(key=attrgetter('start_time')) context['active_participations'] = active context['current_contests'] = present context['future_contests'] = future @@ -219,9 +225,13 @@ def get_context_data(self, **kwargs): active.append(participation) present.remove(participation.contest) - active.sort(key=attrgetter('end_time', 'key')) - present.sort(key=attrgetter('end_time', 'key')) - future.sort(key=attrgetter('start_time')) + active.sort(key=lambda p: (p.contest.sort_order, p.contest.key)) + present.sort(key=lambda c: (c.sort_order, c.key)) + future.sort(key=lambda c: (c.sort_order, c.key)) + + # active.sort(key=attrgetter('end_time', 'key')) + # present.sort(key=attrgetter('end_time', 'key')) + # future.sort(key=attrgetter('start_time')) context['title'] = _('Các kỳ thi thuộc mục "%s"') % self.tag.display_name context['active_participations'] = active context['current_contests'] = present @@ -231,6 +241,7 @@ def get_context_data(self, **kwargs): context['first_page_href'] = '.' context['page_suffix'] = '#past-contests' context['search_query'] = self.search_query + context['current_tag_key'] = self.tag.key if self.tag else None context.update(self.get_sort_context()) context.update(self.get_sort_paginate_context()) return context @@ -1744,3 +1755,44 @@ def get(self, request, *args, **kwargs): response['Content-Type'] = 'application/zip' response['Content-Disposition'] = 'attachment; filename=%s-data.zip' % self.object.key return response + + +@method_decorator(staff_member_required, name='dispatch') +class ContestReorder(View): + def get(self, request): + now = timezone.now() + contests = Contest.objects.prefetch_related('tags', 'organizations').order_by('sort_order', 'key') + + year = request.GET.get('year') + tag_key = request.GET.get('tag') + + if year: + contests = contests.filter(start_time__year=year) + if tag_key: + contests = contests.filter(tags__key=tag_key) + + all_years = Contest.objects.dates('start_time', 'year', order='DESC') + all_tags = ContestTag.objects.all().order_by('key') + return render(request, 'contest/reorder.html', { + 'title': _('Reorder contests'), + 'future_contests': contests.filter(start_time__gt=now), + 'current_contests': contests.filter(start_time__lte=now, end_time__gte=now), + 'past_contests': contests.filter(end_time__lt=now), + 'all_years': [d.year for d in all_years], + 'all_tags': all_tags, + 'current_year': year, + 'current_tag': tag_key, + }) + + def post(self, request): + data = json.loads(request.body) + for section in ('future', 'present', 'past'): + keys = data.get(section, []) + if not keys: + continue + current_orders = sorted( + Contest.objects.filter(key__in=keys).values_list('sort_order', flat=True), + ) + for key, sort_val in zip(keys, current_orders): + Contest.objects.filter(key=key).update(sort_order=sort_val) + return JsonResponse({'status': 'ok'}) diff --git a/locale/vi/LC_MESSAGES/django.po b/locale/vi/LC_MESSAGES/django.po index d32af3cff..6a7e99554 100644 --- a/locale/vi/LC_MESSAGES/django.po +++ b/locale/vi/LC_MESSAGES/django.po @@ -7656,3 +7656,69 @@ msgstr "File logo" #: judge/models/contest.py: 203 msgid "rank display options" msgstr "Hiển thị đại diện trong rank" + +#: judge/models/profile.py:170 +msgid "unpublic logo" +msgstr "Không công khai logo" + +#: judge/models/profile.py:171 +msgid "If enabled, only selected users or users within the organization selected below will be able to use the logo. Leave the fields below blank so no one can see this logo" +msgstr "Nếu bật, chỉ những người dùng được chọn hoặc người dùng trong tổ chức được chọn bên dưới mới có thể sử dụng logo. Để trống những trường bên dưới để ẩn đi logo này" + +#: judge/models/profile.py:175 +msgid "users are permitted" +msgstr "Người dùng được phép" + +#: judge/models/profile.py:176 +msgid "If private, only these users may choose the logo" +msgstr "Nếu cho private, chỉ những người dùng trên mới được chọn logo" + +#: judge/models/profile.py:178 +msgid "If private, only these organizations may choose the logo" +msgstr "Nếu cho private, chỉ những tổ chức trên mới có thể chọn logo" + +#: judge/forms.py:93 +#, python-format +msgid "You are not allowed to use this logo" +msgstr "" +"Bạn không có quyền sử dụng logo này!" + +#: judge/admin/profile.py:62 +msgid "This user is not allowed to use this logo" +msgstr "Người dùng này không có quyền sử dụng logo này!" + +#: judge/view/contest.py:1776 +msgid "Reorder contests" +msgstr "Sắp xếp lại các cuộc thi" + +#: templates/contest/reorder.html +msgid "Contest year" +msgstr "Năm tổ chức" + +#: templates/contest/reorder.html +msgid "Contest tag" +msgstr "Thẻ kỳ thi" + +#: templates/contest/reorder.html +msgid "Clear filters" +msgstr "Xoá bộ lọc" + +#: templates/contest/reorder.html +msgid "Save order" +msgstr "Lưu thứ tự này" + +#: templates/contest/reorder.html +msgid "No contests" +msgstr "Không có kỳ thi" + +#: templates/contest/reorder.html +msgid "Order saved" +msgstr "Đã cập nhật thứ tự này" + +#: templates/contest/reorder.html +msgid "Error while saving order" +msgstr "Lỗi khi cập nhật thứ tự mới" + +#: templates/contest/reorder.html +msgid "Your changes may not have been saved yet. Do you want to leave the page" +msgstr "Các thay đổi của bạn có thể chưa được lưu. Bạn có muốn rời khỏi trang không" diff --git a/resources/Sortable.min.js b/resources/Sortable.min.js new file mode 100644 index 000000000..4d6c09744 --- /dev/null +++ b/resources/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.0 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function M(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function N(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&p(t,e)||o&&t===n)return t}while(t!==n&&(t=(i=t).host&&i!==document&&i.host.nodeType?i.host:i.parentNode))}var i;return null}var g,m=/\s+/g;function I(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(m," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(m," ")))}function P(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function v(t,e){var n="";if("string"==typeof t)n=t;else do{var o=P(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function b(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[j]._onDragOver(o)}}var i,r,a}function Yt(t){q&&q.parentNode[j]._isOutsideThisEl(t.target)}function Bt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[j]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return It(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==Bt.supportPointer&&"PointerEvent"in window&&!u,emptyInsertThreshold:5};for(n in K.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Pt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&Mt,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),Et.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,x())}function Ft(t,e,n,o,i,r,a,l){var s,c,u=t[j],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||k(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function jt(t){t.draggable=!1}function Ht(){Ct=!1}function Lt(t){return setTimeout(t,0)}function Kt(t){return clearTimeout(t)}Bt.prototype={constructor:Bt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(gt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,q):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Tt.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Tt.push(o)}}(o),!q&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=N(l,t.draggable,o,!1))&&l.animated||J===l)){if(nt=B(l),it=B(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return U({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),z("filter",n,{evt:e}),void(i&&e.cancelable&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=N(s,t.trim(),o,!1))return U({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),z("filter",n,{evt:e}),!0}))return void(i&&e.cancelable&&e.preventDefault());t.handle&&!N(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!q&&n.parentNode===r&&(o=k(n),$=r,V=(q=n).parentNode,Q=q.nextSibling,J=n,at=a.group,st={target:Bt.dragged=q,clientX:(e||t).clientX,clientY:(e||t).clientY},ht=st.clientX-o.left,ft=st.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,q.style["will-change"]="all",o=function(){z("delayEnded",i,{evt:t}),Bt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(q.draggable=!0),i._triggerDragStart(t,e),U({sortable:i,name:"choose",originalEvent:t}),I(q,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){b(q,t.trim(),jt)}),h(l,"dragover",Xt),h(l,"mousemove",Xt),h(l,"touchmove",Xt),h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,q.draggable=!0),z("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():Bt.eventCanceled?this._onDrop():(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){q&&jt(q),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;f(t,"mouseup",this._disableDelayedDrag),f(t,"touchend",this._disableDelayedDrag),f(t,"touchcancel",this._disableDelayedDrag),f(t,"mousemove",this._delayedDragTouchMoveHandler),f(t,"touchmove",this._delayedDragTouchMoveHandler),f(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(q,"dragend",this),h($,"dragstart",this._onDragStart));try{document.selection?Lt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;yt=!1,$&&q?(z("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Yt),n=this.options,t||I(q,n.dragClass,!1),I(q,n.ghostClass,!0),Bt.active=this,t&&this._appendGhost(),U({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(ct){this._lastX=ct.clientX,this._lastY=ct.clientY,kt();for(var t=document.elementFromPoint(ct.clientX,ct.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(ct.clientX,ct.clientY))!==e;)e=t;if(q.parentNode[j]._isOutsideThisEl(t),e)do{if(e[j])if(e[j]._onDragOver({clientX:ct.clientX,clientY:ct.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=(t=e).parentNode);Rt()}},_onTouchMove:function(t){if(st){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Z&&v(Z,!0),a=Z&&r&&r.a,l=Z&&r&&r.d,e=Ot&&bt&&E(bt),a=(i.clientX-st.clientX+o.x)/(a||1)+(e?e[0]-_t[0]:0)/(a||1),l=(i.clientY-st.clientY+o.y)/(l||1)+(e?e[1]-_t[1]:0)/(l||1);if(!Bt.active&&!yt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))n.right+10||t.clientX<=n.right&&t.clientY>n.bottom&&t.clientX>=n.left:t.clientX>n.right&&t.clientY>n.top||t.clientX<=n.right&&t.clientY>n.bottom+10}(n,r,this)&&!g.animated){if(g===q)return O(!1);if((l=g&&a===n.target?g:l)&&(w=k(l)),!1!==Ft($,a,q,o,l,w,n,!!l))return x(),g&&g.nextSibling?a.insertBefore(q,g.nextSibling):a.appendChild(q),V=a,A(),O(!0)}else if(g&&function(t,e,n){n=k(X(n.el,0,n.options,!0));return e?t.clientX{{ _('Reorder contests') }} +{% endblock %} + +{% block content_js_media %} + +{% endblock %} + +{% block body %} + + +
+
+
+ + +
+ +
+ + +
+ + {% if current_year or current_tag %} + + {{ _('Clear filters') }} + {% endif %} +
+ + {% macro sortable_section(title, contests, section_id) %} +

{{ title }}

+
+ + + + + + + + + + {% for contest in contests %} + + + + + + {% else %} + + {% endfor %} + +
{{ _('Contest') }}{{ _('Users') }}
+
+ + {{ contest.name }} + + + {% if not contest.is_visible %} + + {{ _('hidden') }} + + {% endif %} + {% if contest.is_organization_private %} + {% for org in contest.organizations.all() %} + + + {{ org.name }} + + + {% endfor %} + {% elif contest.is_private %} + + {{ _('private') }} + + {% endif %} + {% if contest.is_rated %} + + {{ _('rated') }} + + {% endif %} + {% for tag in contest.tags.all() %} + + + {{ tag.display_name }} + + + {% endfor %} + +
+
+ {{ contest.start_time|date(_("M j, Y, G:i")) }} + {% if contest.time_limit %} + - {{ contest.end_time|date(_("M j, Y, G:i")) }} + {% endif %} +
+
+
{{ contest.user_count }}
{{ _('No contests') }}
+
+
+ {% endmacro %} + + {{ sortable_section(_('Ongoing contests'), current_contests, 'present') }} + {{ sortable_section(_('Upcoming contests'), future_contests, 'future') }} + {{ sortable_section(_('Past contests'), past_contests, 'past') }} + + +
+ + +{% endblock %} \ No newline at end of file