core&ui: add all enroll users status in training (#456)

Co-authored-by: undefined <i@undefined.moe>
pull/468/head
panda 2 years ago committed by GitHub
parent d02934f766
commit c2f5541e83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -49,6 +49,7 @@ manage: System Manage
no_translation_warn: <blockquote class="warn">This part of content is under translation.</blockquote>
page.problem_detail.sidebar.show_category: Click to Show
page.training_detail.invalid_when_not_enrolled: You cannot view problem details unless enrolled.
page.training_detail.see_other_user_detail: You are viewing user {0} training details.
pager_first: « First
pager_last: Last »
pager_next: Next

@ -482,6 +482,7 @@ My Files: 我的文件
My Profile: 我的资料
My Recent Submissions: 我的最近递交记录
My Role: 我的角色
My status: 我的状态
name: 名称
Name: 名称
Network error: 网络错误
@ -537,6 +538,7 @@ Output Format: 输出格式
Owner: 所有者
page.problem_detail.sidebar.show_category: 点击显示
page.training_detail.invalid_when_not_enrolled: 未参加训练计划时您不能查看题目详情。
page.training_detail.see_other_user_detail: 您正在查看用户 {0} 的训练详情。
pager_first: « 第一页
pager_last: 末页 »
pager_next: 下一页

@ -91,16 +91,30 @@ class TrainingMainHandler extends Handler {
class TrainingDetailHandler extends Handler {
@param('tid', Types.ObjectID)
async get(domainId: string, tid: ObjectID) {
@param('uid', Types.PositiveInt, true)
@param('pjax', Types.Boolean, true)
async get(domainId: string, tid: ObjectID, uid: number, pjax: boolean) {
const tdoc = await training.get(domainId, tid);
await bus.parallel('training/get', tdoc, this);
let targetUser = this.user._id;
let enrollUsers = [];
let shouldCompare = false;
const pids = training.getPids(tdoc.dag);
if (this.user.hasPriv(PRIV.PRIV_USER_PROFILE)) {
enrollUsers = (await training.getMultiStatus(domainId, { docId: tid, uid: { $gt: 1 } })
.project({ uid: 1 }).limit(500).toArray()).map((x) => +x.uid);
if (uid) {
targetUser = uid;
shouldCompare = targetUser !== this.user._id;
}
}
const canViewHidden = this.user.hasPerm(PERM.PERM_VIEW_PROBLEM_HIDDEN) || this.user._id;
const [owner, pdict] = await Promise.all([
user.getById(domainId, tdoc.owner),
const [udict, pdict] = await Promise.all([
user.getList(domainId, [tdoc.owner, ...enrollUsers]),
problem.getList(domainId, pids, canViewHidden, true),
]);
const psdict = await problem.getListStatus(domainId, this.user._id, pids);
const psdict = await problem.getListStatus(domainId, targetUser, pids);
const selfPsdict = shouldCompare ? await problem.getListStatus(domainId, this.user._id, pids) : {};
const donePids = new Set<number>();
const progPids = new Set<number>();
for (const pid in psdict) {
@ -134,10 +148,13 @@ class TrainingDetailHandler extends Handler {
donePids: Array.from(donePids),
done: doneNids.size === tdoc.dag.length,
});
this.response.template = 'training_detail.html';
this.response.body = {
tdoc, tsdoc, pids, pdict, psdict, ndict, nsdict, owner,
tdoc, tsdoc, pids, pdict, psdict, ndict, nsdict, udict, enrollUsers, selfPsdict,
};
if (pjax) {
const html = await this.renderHTML('partials/training_detail.html', this.response.body);
this.response.body = { fragments: [{ html }] };
} else this.response.template = 'training_detail.html';
}
@param('tid', Types.ObjectID)

@ -224,7 +224,7 @@ export function requireSudo(target: any, funcName: string, obj: any) {
method: this.request.method,
referer: this.request.headers.referer,
args: this.args,
redirect: this.request.path,
redirect: this.request.originalPath,
};
this.response.redirect = this.url('user_sudo');
return 'cleanup';

@ -39,7 +39,7 @@ const onmessage = (msg) => {
const url = new URL(`${UiContext.ws_prefix}home/messages-conn`, window.location.href);
// TODO handle a better way for cookie
url.searchParams.append('sid', document.cookie);
if (url.host !== window.location.host) url.searchParams.append('sid', document.cookie.split('sid=')[1].split(';')[0]);
const endpoint = url.toString().replace('http', 'ws');
const initWorkerMode = () => {

@ -3,6 +3,7 @@ import ReconnectingWebSocket from 'reconnecting-websocket';
export default class Sock {
constructor(url) {
const i = new URL(url, window.location.href);
if (i.host !== window.location.host) i.searchParams.append('sid', document.cookie.split('sid=')[1].split(';')[0]);
i.protocol = i.protocol.replace('http', 'ws');
this.url = i.toString();
this.sock = new ReconnectingWebSocket(this.url);

@ -16,6 +16,10 @@
.mode--scratchpad .scratchpad--hide
display: none
.mobile--hide
+mobile()
display: none
::-webkit-scrollbar
width: 8px;
height: 8px;

@ -3,6 +3,9 @@
width: rem(150px)
position: relative
&.col--status--sm
width: rem(84px)
.col--tried, .col--ac, .col--difficulty
width: rem(70px)
text-align: center
@ -33,3 +36,13 @@
&.collapsed .training__section__detail
display: none
#menu-item-training_detail ul
max-height: calc(85vh - 345px)
overflow-y: auto
overflow-x: hidden
.enroll_user_menu
max-height: 85vh
overflow-y: auto
overflow-x: hidden

@ -1,25 +1,82 @@
import $ from 'jquery';
import _ from 'lodash';
import { NamedPage } from 'vj/misc/Page';
import pjax from 'vj/utils/pjax';
import { slideDown, slideUp } from 'vj/utils/slide';
async function handleSection(ev, type: string) {
const $section = $(ev.currentTarget).closest('.training__section');
if ($section.is(`.${type}d, .animating`)) return;
type SectionAction = 'expand' | 'collapse';
type SectionState = 'expanded' | 'collapsed';
function action2state(action: SectionAction): SectionState {
return action === 'expand' ? 'expanded' : 'collapsed';
}
async function setSectionState($section: JQuery<HTMLElement>, state: SectionState) {
if ($section.is(`.${state}, .animating`)) return;
$section.addClass('animating');
const $detail = $section.find('.training__section__detail');
if (type === 'expand') {
if (state === 'expanded') {
await slideDown($detail, 300, { opacity: 0 }, { opacity: 1 });
} else {
await slideUp($detail, 300, { opacity: 1 }, { opacity: 0 });
}
$section.addClass(type === 'expand' ? 'expanded' : 'collapsed');
$section.removeClass(type === 'expand' ? 'collapsed' : 'expanded');
$section.addClass(state);
$section.removeClass(state === 'expanded' ? 'collapsed' : 'expanded');
$section.removeClass('animating');
}
async function handleSection(ev: JQuery.ClickEvent<Document>, type: SectionAction) {
const $section = $(ev.currentTarget).closest('.training__section');
await setSectionState($section, action2state(type));
}
function searchUser() {
const val = $('input[name=uid]').val().toString().toLowerCase();
$('.enroll_user_menu_item').each((i, e) => {
const $item = $(e);
const $username = $item.data('uname').toString().toLowerCase();
const $uid = $item.data('uid').toString();
$item.toggle($username.includes(val) || $uid === val);
});
}
function selectUser(ev) {
ev.preventDefault();
if ($('.enroll_user_menu_item:visible').length === 1) {
$('.enroll_user_menu_item:visible').first().find('a')[0].click();
}
}
function handleChooseUser(ev) {
ev.preventDefault();
$('.enroll_user_menu_item .active').removeClass('active');
$(ev.currentTarget).addClass('active');
pjax.request({ url: ev.currentTarget.href });
}
async function handleSidebarClick(ev: JQuery.ClickEvent<Document>) {
const id = $(ev.currentTarget).attr('href');
const $section = $(id).closest('.training__section');
await setSectionState($section, 'expanded');
}
async function handleHashChange() {
const id = window.location.hash;
if (id.startsWith('#node-')) {
const $section = $(id).closest('.training__section');
await setSectionState($section, 'expanded');
}
}
const page = new NamedPage('training_detail', () => {
$('.search__input').on('input', _.debounce(searchUser, 500));
$('#searchForm').on('submit', selectUser);
$(document).on('click', '[name="training__section__expand"]', (ev) => handleSection(ev, 'expand'));
$(document).on('click', '[name="training__section__collapse"]', (ev) => handleSection(ev, 'collapse'));
$(document).on('click', '.enroll_user_menu_item > a', (ev) => handleChooseUser(ev));
$(document).on('click', '#menu-item-training_detail > ul > li > a', (ev) => handleSidebarClick(ev));
window.addEventListener('hashchange', handleHashChange);
$(handleHashChange);
});
export default page;

@ -0,0 +1,164 @@
{% import "components/record.html" as record with context %}
{% import "components/problem.html" as problem with context %}
{% set expanded = 0 %}
<div data-fragment-id="training_detail">
{% if handler.request.query.uid and handler.request.query.uid != handler.user._id %}
<blockquote class="typo note">
<p>{{ _('page.training_detail.see_other_user_detail').format(udict[handler.request.query.uid].uname) }}</p>
</blockquote>
{% endif %}
{%- for node in tdoc['dag'] -%}
<div data-heading-extract-to="#menu-item-training_detail" class="training__section {% if expanded < 5 and (nsdict[node['_id']]['isProgress'] or nsdict[node['_id']]['isOpen']) %}expanded{% set expanded = expanded + 1 %}{% else %}collapsed{% endif %}">
<div class="section__header clearfix">
<div class="float-left">
<h1 data-heading id="node-{{ node._id }}" class="section__title">{{ _('Section') }} {{ node['_id'] }}. {{ node['title'].split('\n')[0] }}</h1>
{% if node['title'].split('\n')[1] %}<h4>{{ node['title'].split('\n')[1] }}</h4>{% endif %}
</div>
<div class="float-right">
<h1 class="section__title training-section-status--text {% if nsdict[node['_id']]['isDone'] %}done{% elif nsdict[node['_id']]['isProgress'] %}progress{% elif nsdict[node['_id']]['isOpen'] %}open{% else %}invalid{% endif %}">
<span class="icon training-section-status--icon {% if nsdict[node['_id']]['isDone'] %}done{% elif nsdict[node['_id']]['isProgress'] %}progress{% elif nsdict[node['_id']]['isOpen'] %}open{% else %}invalid{% endif %}"></span>
{% if nsdict[node['_id']]['isDone'] %}
{{ _('Completed') }}
{% elif nsdict[node['_id']]['isProgress'] %}
{{ _('In Progress') }}
{% elif nsdict[node['_id']]['isOpen'] %}
{{ _('Open') }}
{% else %}
{{ _('Invalid') }}
{% endif %}
</h1>
</div>
</div>
<div class="section__body">
<ul class="supplementary dot list">
<li>
<a href="javascript:;" name="training__section__expand" class="expanded--hidden"><span class="icon icon-expand_more"></span> {{ _('expand') }}</a>
<a href="javascript:;" name="training__section__collapse" class="collapsed--hidden"><span class="icon icon-expand_less"></span> {{ _('collapse') }}</a>
</li>
<!--
TODO(twd2): twd2 todo
<li>
递交了 ? 次完成该章节 (TOP ?%)
</li>
<li>
第 ? 个完成该章节 (总计 ? 用户完成)
</li>
-->
</ul>
</div>
<div class="training__section__detail">
{% if nsdict[node['_id']]['isInvalid'] %}
<div class="section__body">
<blockquote class="typo note">
<p>{{ _('This section cannot be challenged at present, so please complete the following sections first') }}:</p>
<ul>
{%- for nid in node['requireNids'] -%}
<li>{{ _('Section') }} {{ _(nid) }}. {{ ndict[nid]['title'] }} ({{ _('Completed') }} {{ nsdict[nid]['progress'] }}%)</li>
{%- endfor -%}
</ul>
</blockquote>
</div>
{% endif %}
{% if node['content'] %}
<div class="section__body typo">
{{ node['content']|markdown|safe }}
</div>
{% endif %}
<div class="section__body no-padding training__problems">
{% set should_compare = (handler.request.query.uid or handler.user._id) != handler.user._id and tsdoc["enroll"] %}
<table class="data-table">
<colgroup>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
{% if should_compare %}
<col class="col--status col--status--sm">
<col class="col--status col--status--sm">
{% else %}
<col class="col--status">
{% endif %}
{% endif %}
<col class="col--name">
<col class="col--tried">
<col class="col--ac">
<col class="col--difficulty">
</colgroup>
<thead>
<tr>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
{% if should_compare %}
<th class="col--status col--status--sm record-status--border">{{ _('Status') }}</th>
<th class="col--status col--status--sm">{{ _('My status') }}</th>
{% else %}
<th class="col--status record-status--border">{{ _('Status') }}</th>
{% endif %}
{% endif %}
<th class="col--name">{{ _('Problem') }}</th>
<th class="col--tried">{{ _('Tried') }}</th>
<th class="col--ac">{{ _('AC') }}</th>
<th class="col--difficulty">{{ _('Difficulty') }}</th>
</tr>
</thead>
<tbody>
{%- for pid in node['pids'] -%}
{% if pid in pdict %}
{% set pdoc=pdict[pid] %}
{% set psdoc=psdict[pid] %}
{% set self_psdoc=selfPsdict[pid] %}
<tr>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
{% if psdoc['rid'] %}
{{ record.render_status_td(psdoc, rid_key='rid', short=should_compare) }}
{% else %}
<td class="col--status record-status--border"></td>
{% endif %}
{% if should_compare %}
{% if self_psdoc['rid'] %}
{{ record.render_status_td(self_psdoc, rid_key='rid', short=true) }}
{% else %}
<td class="col--status col--status--sm"></td>
{% endif %}
{% endif %}
{% endif %}
<td class="col--name col--problem-name">
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<form class="form--inline" action="{{ url('problem_main') }}" method="post">
<input type="hidden" name="pid" value="{{ pdoc.docId }}">
<input type="hidden" name="operation" value="{% if not psdoc['star'] %}star{% else %}unstar{% endif %}">
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
<button class="star{% if psdoc['star'] %} activated{% endif %}" type="submit">
<span class="starred--hide"><span class="icon icon-star--outline" data-tooltip="{{ _('Star') }}"></span></span>
<span class="starred--show"><span class="icon icon-star" data-tooltip="{{ _('Unstar') }}"></span></span>
</button>
</form>
{% endif %}
{{ problem.render_problem_title(
pdoc,
invalid=not tsdoc['enroll'],
show_tags=false
) }}
</td>
<td class="col--tried">{{ pdoc.nSubmit }}</td>
<td class="col--ac">{{ pdoc.nAccept }}</td>
<td class="col--difficulty">{{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }}</td>
</tr>
{% else %}
{% set pdoc = {'_id': pid, 'hidden': true, 'title': '*'} %}
<tr>
<td class="col--status record-status--border">
</td>
<td class="col--name col--problem-name">
{{ problem.render_problem_title(pdoc, invalid=true) }}
</td>
<td class="col--tried">*</td>
<td class="col--ac">*</td>
<td class="col--difficulty">*</td>
</tr>
{% endif %}
{%- endfor -%}
</tbody>
</table>
</div>
</div>
</div>
{%- endfor -%}
<div class="section__body">&nbsp;<!-- space placeholder --></div>
</div>

@ -40,7 +40,7 @@
<input name="tid" type="text" class="textbox" value="{{ filterTid }}">
</label>
</div>
<div class="medium-3 columns nojs-hide">
<div class="medium-3 columns">
<label>
{{ _('By Language') }}
</label>

@ -1,9 +1,40 @@
{% import "components/record.html" as record with context %}
{% import "components/problem.html" as problem with context %}
{% extends "layout/basic.html" %}
{% block content %}
<div class="row">
<div class="row" data-sticky-parent>
{%- if enrollUsers.length -%}
<div class="medium-2 columns mobile--hide">
<div data-sticky="large"><div class="section side">
<ol class="menu">
<li class="menu__item nojs--hide">
<form method="get" id="searchForm">
<input name="uid" type="text" class="search__input textbox" placeholder="{{ _('Select User') }}">
</form>
</li>
<ul class="enroll_user_menu">
{%- for uid in enrollUsers -%}
<li class="menu__item enroll_user_menu_item" data-uid="{{ uid }}" data-uname="{{ udict[uid].uname }}">
<a href="./{{ tdoc._id }}?uid={{ uid }}" class="menu__link{% if (handler.request.query.uid or handler.user._id) == uid %} active{% endif %}">
<span class="user-profile-link">
<img class="small user-profile-avatar v-center" loading="lazy" src="{{ avatarUrl(udict[uid].avatar|default('')) }}" width="20" height="20">
<span class="user-profile-name">
{% if handler.user.hasPerm(perm.PREM_VIEW_DISPLAYNAME) and udict[uid].displayName and udict[uid].displayName != udict[uid].uname %}
{{ udict[uid].displayName }} ({{ udict[uid].uname }})
{% else %}
{{ udict[uid].uname }}
{% endif %}
</span>
</span>
</a>
</li>
{%- endfor -%}
</ul>
</ol>
</div></div>
</div>
<div class="medium-7 columns">
{%- else -%}
<div class="medium-9 columns">
{%- endif -%}
<div class="section">
<div class="section__body typo">
{{ tdoc['content'] }}
@ -22,141 +53,7 @@
</div>
</div>
<div class="section">
{%- for node in tdoc['dag'] -%}
<div data-heading-extract-to="#menu-item-training_detail" class="training__section {% if nsdict[node['_id']]['isDone'] %}done{% elif nsdict[node['_id']]['isProgress'] %}progress{% elif nsdict[node['_id']]['isOpen'] %}open{% else %}invalid{% endif %} {% if nsdict[node['_id']]['isProgress'] or nsdict[node['_id']]['isOpen'] %}expanded{% else %}collapsed{% endif %}">
<div class="section__header clearfix">
<div class="float-left">
<h1 data-heading id="node-{{ node._id }}" class="section__title">{{ _('Section') }} {{ node['_id'] }}. {{ node['title'].split('\n')[0] }}</h1>
{% if node['title'].split('\n')[1] %}<h4>{{ node['title'].split('\n')[1] }}</h4>{% endif %}
</div>
<div class="float-right">
<h1 class="section__title training-section-status--text {% if nsdict[node['_id']]['isDone'] %}done{% elif nsdict[node['_id']]['isProgress'] %}progress{% elif nsdict[node['_id']]['isOpen'] %}open{% else %}invalid{% endif %}">
<span class="icon training-section-status--icon {% if nsdict[node['_id']]['isDone'] %}done{% elif nsdict[node['_id']]['isProgress'] %}progress{% elif nsdict[node['_id']]['isOpen'] %}open{% else %}invalid{% endif %}"></span>
{% if nsdict[node['_id']]['isDone'] %}
{{ _('Completed') }}
{% elif nsdict[node['_id']]['isProgress'] %}
{{ _('In Progress') }}
{% elif nsdict[node['_id']]['isOpen'] %}
{{ _('Open') }}
{% else %}
{{ _('Invalid') }}
{% endif %}
</h1>
</div>
</div>
<div class="section__body">
<ul class="supplementary dot list">
<li>
<a href="javascript:;" name="training__section__expand" class="expanded--hidden"><span class="icon icon-expand_more"></span> {{ _('expand') }}</a>
<a href="javascript:;" name="training__section__collapse" class="collapsed--hidden"><span class="icon icon-expand_less"></span> {{ _('collapse') }}</a>
</li>
<!--
TODO(twd2): twd2 todo
<li>
递交了 ? 次完成该章节 (TOP ?%)
</li>
<li>
第 ? 个完成该章节 (总计 ? 用户完成)
</li>
-->
</ul>
</div>
<div class="training__section__detail">
{% if nsdict[node['_id']]['isInvalid'] %}
<div class="section__body">
<blockquote class="typo note">
<p>{{ _('This section cannot be challenged at present, so please complete the following sections first') }}:</p>
<ul>
{%- for nid in node['requireNids'] -%}
<li>{{ _('Section') }} {{ _(nid) }}. {{ ndict[nid]['title'] }} ({{ _('Completed') }} {{ nsdict[nid]['progress'] }}%)</li>
{%- endfor -%}
</ul>
</blockquote>
</div>
{% endif %}
{% if node['content'] %}
<div class="section__body typo">
{{ node['content']|markdown|safe }}
</div>
{% endif %}
<div class="section__body no-padding training__problems">
<table class="data-table">
<colgroup>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<col class="col--status">
{% endif %}
<col class="col--name">
<col class="col--tried">
<col class="col--ac">
<col class="col--difficulty">
</colgroup>
<thead>
<tr>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<th class="col--status record-status--border">{{ _('Status') }}</th>
{% endif %}
<th class="col--name">{{ _('Problem') }}</th>
<th class="col--tried">{{ _('Tried') }}</th>
<th class="col--ac">{{ _('AC') }}</th>
<th class="col--difficulty">{{ _('Difficulty') }}</th>
</tr>
</thead>
<tbody>
{%- for pid in node['pids'] -%}
{% if pid in pdict %}
{% set pdoc=pdict[pid] %}
{% set psdoc=psdict[pid] %}
<tr>
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
{% if psdoc['rid'] %}
{{ record.render_status_td(psdoc, rid_key='rid') }}
{% else %}
<td class="col--status record-status--border"></td>
{% endif %}
{% endif %}
<td class="col--name col--problem-name">
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
<form class="form--inline" action="{{ url('problem_main') }}" method="post">
<input type="hidden" name="pid" value="{{ pdoc.docId }}">
<input type="hidden" name="operation" value="{% if not psdoc['star'] %}star{% else %}unstar{% endif %}">
<input type="hidden" name="csrfToken" value="{{ handler.csrfToken }}">
<button class="star{% if psdoc['star'] %} activated{% endif %}" type="submit">
<span class="starred--hide"><span class="icon icon-star--outline" data-tooltip="{{ _('Star') }}"></span></span>
<span class="starred--show"><span class="icon icon-star" data-tooltip="{{ _('Unstar') }}"></span></span>
</button>
</form>
{% endif %}
{{ problem.render_problem_title(
pdoc,
invalid=not tsdoc['enroll'],
show_tags=false
) }}
</td>
<td class="col--tried">{{ pdoc.nSubmit }}</td>
<td class="col--ac">{{ pdoc.nAccept }}</td>
<td class="col--difficulty">{{ pdoc['difficulty'] or lib.difficulty(pdoc.nSubmit, pdoc.nAccept) or _('(None)') }}</td>
</tr>
{% else %}
{% set pdoc = {'_id': pid, 'hidden': true, 'title': '*'} %}
<tr>
<td class="col--status record-status--border">
</td>
<td class="col--name col--problem-name">
{{ problem.render_problem_title(pdoc, invalid=true) }}
</td>
<td class="col--tried">*</td>
<td class="col--ac">*</td>
<td class="col--difficulty">*</td>
</tr>
{% endif %}
{%- endfor -%}
</tbody>
</table>
</div>
</div>
</div>
{%- endfor -%}
<div class="section__body">&nbsp;<!-- space placeholder --></div>
{% include "partials/training_detail.html" %}
</div>
</div>
<div class="medium-3 columns" data-sticky="large">
@ -195,15 +92,15 @@
{% endif %}
<dt>{{ _('Enrollees') }}</dt><dd>{{ tdoc.attend|default(0) }}</dd>
<dt>{{ _('Created By') }}</dt>
<dd>{{ user.render_inline(owner, badge=false) }}</dd>
<dd>{{ user.render_inline(udict[tdoc.owner], badge=false) }}</dd>
</dl>
</div>
</div>
<div class="section side"><div data-sticky="large">
<div class="section side">
<ol class="menu">
{{ sidemenu.render_item('list', 'training_detail', {tid:tdoc._id}, label='tasks_list', menu_item='training_detail') }}
</ol>
</div></div>
</div>
</div>
</div>
{% endblock %}
Loading…
Cancel
Save