import json from calendar import Calendar, SUNDAY from collections import defaultdict, namedtuple from datetime import date, datetime, time, timedelta from functools import partial from itertools import chain from operator import attrgetter, itemgetter from django import forms from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.core.cache import cache from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.db import IntegrityError from django.db.models import Case, Count, FloatField, IntegerField, Max, Min, Q, Sum, Value, When from django.db.models.expressions import CombinedExpression from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.template.defaultfilters import date as date_filter from django.urls import reverse from django.utils import timezone from django.utils.functional import cached_property from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.timezone import make_aware from django.utils.translation import gettext as _, gettext_lazy from django.views.generic import ListView, TemplateView from django.views.generic.detail import BaseDetailView, DetailView, SingleObjectMixin, View from judge import event_poster as event from judge.comments import CommentedDetailView from judge.forms import ContestCloneForm from judge.models import Contest, ContestMoss, ContestParticipation, ContestProblem, ContestTag, \ Organization, Problem, Profile, Submission from judge.tasks import run_moss from judge.utils.celery import redirect_to_task_status from judge.utils.opengraph import generate_opengraph from judge.utils.problems import _get_result_data from judge.utils.ranker import ranker from judge.utils.stats import get_bar_chart, get_pie_chart from judge.utils.views import DiggPaginatorMixin, SingleObjectFormView, TitleMixin, generic_message __all__ = ['ContestList', 'ContestDetail', 'ContestRanking', 'ContestJoin', 'ContestLeave', 'ContestCalendar', 'ContestClone', 'ContestStats', 'ContestMossView', 'ContestMossDelete', 'contest_ranking_ajax', 'ContestParticipationList', 'ContestParticipationDisqualify', 'get_contest_ranking_list', 'base_contest_ranking_list'] def _find_contest(request, key, private_check=True): try: contest = Contest.objects.get(key=key) if private_check and not contest.is_accessible_by(request.user): raise ObjectDoesNotExist() except ObjectDoesNotExist: return generic_message(request, _('No such contest'), _('Could not find a contest with the key "%s".') % key, status=404), False return contest, True class ContestListMixin(object): def get_queryset(self): queryset = Contest.objects.all() if not self.request.user.has_perm('judge.see_private_contest'): q = Q(is_visible=True) if self.request.user.is_authenticated: q |= Q(organizers=self.request.profile) queryset = queryset.filter(q) if not self.request.user.has_perm('judge.edit_all_contest'): q = Q(is_private=False, is_organization_private=False) if self.request.user.is_authenticated: q |= Q(is_organization_private=True, organizations__in=self.request.profile.organizations.all()) q |= Q(is_private=True, private_contestants=self.request.profile) q |= Q(view_contest_scoreboard=self.request.profile) queryset = queryset.filter(q) return queryset.distinct() class ContestList(DiggPaginatorMixin, TitleMixin, ContestListMixin, ListView): model = Contest paginate_by = 20 template_name = 'contest/list.html' title = gettext_lazy('Contests') context_object_name = 'past_contests' @cached_property def _now(self): return timezone.now() def get(self, request, *args, **kwargs): self.contest_query = None self.org_query = [] if 'orgs' in self.request.GET: try: self.org_query = list(map(int, request.GET.getlist('orgs'))) except ValueError: pass return super(ContestList, self).get(request, *args, **kwargs) def _get_queryset(self): queryset = super(ContestList, self).get_queryset() \ .order_by('-start_time', 'key').prefetch_related('tags', 'organizations', 'organizers') if 'contest' in self.request.GET: self.contest_query = query = ' '.join(self.request.GET.getlist('contest')).strip() if query: queryset = queryset.filter( Q(key__icontains=query) | Q(name__icontains=query)) if self.org_query: queryset = queryset.filter(organizations__in=self.org_query) return queryset def get_queryset(self): return self._get_queryset().filter(end_time__lt=self._now) def get_context_data(self, **kwargs): context = super(ContestList, self).get_context_data(**kwargs) present, active, future = [], [], [] for contest in self._get_queryset().exclude(end_time__lt=self._now): if contest.start_time > self._now: future.append(contest) else: present.append(contest) if self.request.user.is_authenticated: for participation in ContestParticipation.objects.filter(virtual=0, user=self.request.profile, contest_id__in=present) \ .select_related('contest').prefetch_related('contest__organizers'): if not participation.ended: active.append(participation) present.remove(participation.contest) active.sort(key=attrgetter('end_time')) future.sort(key=attrgetter('start_time')) context['active_participations'] = active context['current_contests'] = present context['future_contests'] = future context['now'] = self._now context['first_page_href'] = '.' context['contest_query'] = self.contest_query context['org_query'] = self.org_query context['organizations'] = Organization.objects.all() return context class PrivateContestError(Exception): def __init__(self, name, is_private, is_organization_private, orgs): self.name = name self.is_private = is_private self.is_organization_private = is_organization_private self.orgs = orgs class ContestMixin(object): context_object_name = 'contest' model = Contest slug_field = 'key' slug_url_kwarg = 'contest' @cached_property def is_organizer(self): return self.check_organizer() def check_organizer(self, contest=None, user=None): if user is None: user = self.request.user return (contest or self.object).is_editable_by(user) def get_context_data(self, **kwargs): context = super(ContestMixin, self).get_context_data(**kwargs) if self.request.user.is_authenticated: profile = self.request.profile in_contest = context['in_contest'] = (profile.current_contest is not None and profile.current_contest.contest == self.object) if in_contest: context['participation'] = profile.current_contest context['participating'] = True else: try: context['participation'] = profile.contest_history.get(contest=self.object, virtual=0) except ContestParticipation.DoesNotExist: context['participating'] = False context['participation'] = None else: context['participating'] = True else: context['participating'] = False context['participation'] = None context['in_contest'] = False context['now'] = timezone.now() context['is_organizer'] = self.is_organizer if not self.object.og_image or not self.object.summary: metadata = generate_opengraph('generated-meta-contest:%d' % self.object.id, self.object.description, 'contest') context['meta_description'] = self.object.summary or metadata[0] context['og_image'] = self.object.og_image or metadata[1] context['has_moss_api_key'] = settings.MOSS_API_KEY is not None context['logo_override_image'] = self.object.logo_override_image if not context['logo_override_image'] and self.object.organizations.count() == 1: context['logo_override_image'] = self.object.organizations.first().logo_override_image return context def get_object(self, queryset=None): contest = super(ContestMixin, self).get_object(queryset) user = self.request.user profile = self.request.profile if (profile is not None and ContestParticipation.objects.filter(id=profile.current_contest_id, contest_id=contest.id).exists()): return contest if not contest.is_visible and not user.has_perm('judge.see_private_contest') and ( not user.has_perm('judge.edit_own_contest') or not self.check_organizer(contest, user)): raise Http404() if contest.is_private or contest.is_organization_private: private_contest_error = PrivateContestError(contest.name, contest.is_private, contest.is_organization_private, contest.organizations.all()) if profile is None: raise private_contest_error if user.has_perm('judge.edit_all_contest'): return contest if not (contest.is_organization_private and contest.organizations.filter(id__in=profile.organizations.all()).exists()) and \ not (contest.is_private and contest.private_contestants.filter(id=profile.id).exists()): raise private_contest_error return contest def dispatch(self, request, *args, **kwargs): try: return super(ContestMixin, self).dispatch(request, *args, **kwargs) except Http404: key = kwargs.get(self.slug_url_kwarg, None) if key: return generic_message(request, _('No such contest'), _('Could not find a contest with the key "%s".') % key) else: return generic_message(request, _('No such contest'), _('Could not find such contest.')) except PrivateContestError as e: return render(request, 'contest/private.html', { 'error': e, 'title': _('Access to contest "%s" denied') % e.name, }, status=403) class ContestDetail(ContestMixin, TitleMixin, CommentedDetailView): template_name = 'contest/contest.html' def get_comment_page(self): return 'c:%s' % self.object.key def get_title(self): return self.object.name def get_context_data(self, **kwargs): context = super(ContestDetail, self).get_context_data(**kwargs) context['contest_problems'] = Problem.objects.filter(contests__contest=self.object) \ .order_by('contests__order').defer('description') \ .annotate(has_public_editorial=Sum(Case(When(solution__is_public=True, then=1), default=0, output_field=IntegerField()))) \ .add_i18n_name(self.request.LANGUAGE_CODE) return context class ContestClone(ContestMixin, PermissionRequiredMixin, TitleMixin, SingleObjectFormView): title = _('Clone Contest') template_name = 'contest/clone.html' form_class = ContestCloneForm permission_required = 'judge.clone_contest' def form_valid(self, form): contest = self.object tags = contest.tags.all() organizations = contest.organizations.all() private_contestants = contest.private_contestants.all() view_contest_scoreboard = contest.view_contest_scoreboard.all() contest_problems = contest.contest_problems.all() contest.pk = None contest.is_visible = False contest.user_count = 0 contest.key = form.cleaned_data['key'] contest.save() contest.tags.set(tags) contest.organizations.set(organizations) contest.private_contestants.set(private_contestants) contest.view_contest_scoreboard.set(view_contest_scoreboard) contest.organizers.add(self.request.profile) for problem in contest_problems: problem.contest = contest problem.pk = None ContestProblem.objects.bulk_create(contest_problems) return HttpResponseRedirect(reverse('admin:judge_contest_change', args=(contest.id,))) class ContestAccessDenied(Exception): pass class ContestAccessCodeForm(forms.Form): access_code = forms.CharField(max_length=255) def __init__(self, *args, **kwargs): super(ContestAccessCodeForm, self).__init__(*args, **kwargs) self.fields['access_code'].widget.attrs.update({'autocomplete': 'off'}) class ContestJoin(LoginRequiredMixin, ContestMixin, BaseDetailView): def get(self, request, *args, **kwargs): self.object = self.get_object() return self.ask_for_access_code() def post(self, request, *args, **kwargs): self.object = self.get_object() try: return self.join_contest(request) except ContestAccessDenied: if request.POST.get('access_code'): return self.ask_for_access_code(ContestAccessCodeForm(request.POST)) else: return HttpResponseRedirect(request.path) def join_contest(self, request, access_code=None): contest = self.object if not contest.can_join and not self.is_organizer: return generic_message(request, _('Contest not ongoing'), _('"%s" is not currently ongoing.') % contest.name) profile = request.profile if profile.current_contest is not None: return generic_message(request, _('Already in contest'), _('You are already in a contest: "%s".') % profile.current_contest.contest.name) if not request.user.is_superuser and contest.banned_users.filter(id=profile.id).exists(): return generic_message(request, _('Banned from joining'), _('You have been declared persona non grata for this contest. ' 'You are permanently barred from joining this contest.')) requires_access_code = (not (request.user.is_superuser or self.is_organizer) and contest.access_code and access_code != contest.access_code) if contest.ended: if requires_access_code: raise ContestAccessDenied() while True: virtual_id = max((ContestParticipation.objects.filter(contest=contest, user=profile) .aggregate(virtual_id=Max('virtual'))['virtual_id'] or 0) + 1, 1) try: participation = ContestParticipation.objects.create( contest=contest, user=profile, virtual=virtual_id, real_start=timezone.now(), ) # There is obviously a race condition here, so we keep trying until we win the race. except IntegrityError: pass else: break else: try: participation = ContestParticipation.objects.get( contest=contest, user=profile, virtual=(-1 if self.is_organizer else 0), ) except ContestParticipation.DoesNotExist: if requires_access_code: raise ContestAccessDenied() participation = ContestParticipation.objects.create( contest=contest, user=profile, virtual=(-1 if self.is_organizer else 0), real_start=timezone.now(), ) else: if participation.ended: participation = ContestParticipation.objects.get_or_create( contest=contest, user=profile, virtual=-1, defaults={'real_start': timezone.now()}, )[0] profile.current_contest = participation profile.save() contest._updating_stats_only = True contest.update_user_count() return HttpResponseRedirect(reverse('problem_list')) def ask_for_access_code(self, form=None): contest = self.object wrong_code = False if form: if form.is_valid(): if form.cleaned_data['access_code'] == contest.access_code: return self.join_contest(self.request, form.cleaned_data['access_code']) wrong_code = True else: form = ContestAccessCodeForm() return render(self.request, 'contest/access_code.html', { 'form': form, 'wrong_code': wrong_code, 'title': _('Enter access code for "%s"') % contest.name, }) class ContestLeave(LoginRequiredMixin, ContestMixin, BaseDetailView): def post(self, request, *args, **kwargs): contest = self.get_object() profile = request.profile if profile.current_contest is None or profile.current_contest.contest_id != contest.id: return generic_message(request, _('No such contest'), _('You are not in contest "%s".') % contest.key, 404) profile.remove_contest() return HttpResponseRedirect(reverse('contest_view', args=(contest.key,))) ContestDay = namedtuple('ContestDay', 'date weekday is_pad is_today starts ends oneday') class ContestCalendar(TitleMixin, ContestListMixin, TemplateView): firstweekday = SUNDAY weekday_classes = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] template_name = 'contest/calendar.html' def get(self, request, *args, **kwargs): try: self.year = int(kwargs['year']) self.month = int(kwargs['month']) except (KeyError, ValueError): raise ImproperlyConfigured(_('ContestCalendar requires integer year and month')) self.today = timezone.now().date() return self.render() def render(self): context = self.get_context_data() return self.render_to_response(context) def get_contest_data(self, start, end): end += timedelta(days=1) contests = self.get_queryset().filter(Q(start_time__gte=start, start_time__lt=end) | Q(end_time__gte=start, end_time__lt=end)).defer('description') starts, ends, oneday = (defaultdict(list) for i in range(3)) for contest in contests: start_date = timezone.localtime(contest.start_time).date() end_date = timezone.localtime(contest.end_time - timedelta(seconds=1)).date() if start_date == end_date: oneday[start_date].append(contest) else: starts[start_date].append(contest) ends[end_date].append(contest) return starts, ends, oneday def get_table(self): calendar = Calendar(self.firstweekday).monthdatescalendar(self.year, self.month) starts, ends, oneday = self.get_contest_data(make_aware(datetime.combine(calendar[0][0], time.min)), make_aware(datetime.combine(calendar[-1][-1], time.min))) return [[ContestDay( date=date, weekday=self.weekday_classes[weekday], is_pad=date.month != self.month, is_today=date == self.today, starts=starts[date], ends=ends[date], oneday=oneday[date], ) for weekday, date in enumerate(week)] for week in calendar] def get_context_data(self, **kwargs): context = super(ContestCalendar, self).get_context_data(**kwargs) try: month = date(self.year, self.month, 1) except ValueError: raise Http404() else: context['title'] = _('Contests in %(month)s') % {'month': date_filter(month, _("F Y"))} dates = Contest.objects.aggregate(min=Min('start_time'), max=Max('end_time')) min_month = (self.today.year, self.today.month) if dates['min'] is not None: min_month = dates['min'].year, dates['min'].month max_month = (self.today.year, self.today.month) if dates['max'] is not None: max_month = max((dates['max'].year, dates['max'].month), (self.today.year, self.today.month)) month = (self.year, self.month) if month < min_month or month > max_month: # 404 is valid because it merely declares the lack of existence, without any reason raise Http404() context['now'] = timezone.now() context['calendar'] = self.get_table() context['curr_month'] = date(self.year, self.month, 1) if month > min_month: context['prev_month'] = date(self.year - (self.month == 1), 12 if self.month == 1 else self.month - 1, 1) else: context['prev_month'] = None if month < max_month: context['next_month'] = date(self.year + (self.month == 12), 1 if self.month == 12 else self.month + 1, 1) else: context['next_month'] = None return context class CachedContestCalendar(ContestCalendar): def render(self): key = 'contest_cal:%d:%d' % (self.year, self.month) cached = cache.get(key) if cached is not None: return HttpResponse(cached) response = super(CachedContestCalendar, self).render() response.render() cached.set(key, response.content) return response class ContestStats(TitleMixin, ContestMixin, DetailView): template_name = 'contest/stats.html' def get_title(self): return _('%s Statistics') % self.object.name def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if not (self.object.ended or self.object.is_editable_by(self.request.user)): raise Http404() queryset = Submission.objects.filter(contest_object=self.object) ac_count = Count(Case(When(result='AC', then=Value(1)), output_field=IntegerField())) ac_rate = CombinedExpression(ac_count / Count('problem'), '*', Value(100.0), output_field=FloatField()) status_count_queryset = list( queryset.values('problem__code', 'result').annotate(count=Count('result')) .values_list('problem__code', 'result', 'count'), ) labels, codes = zip( *self.object.contest_problems.order_by('order').values_list('problem__name', 'problem__code'), ) num_problems = len(labels) status_counts = [[] for i in range(num_problems)] for problem_code, result, count in status_count_queryset: if problem_code in codes: status_counts[codes.index(problem_code)].append((result, count)) result_data = defaultdict(partial(list, [0] * num_problems)) for i in range(num_problems): for category in _get_result_data(defaultdict(int, status_counts[i]))['categories']: result_data[category['code']][i] = category['count'] stats = { 'problem_status_count': { 'labels': labels, 'datasets': [ { 'label': name, 'backgroundColor': settings.DMOJ_STATS_SUBMISSION_RESULT_COLORS[name], 'data': data, } for name, data in result_data.items() ], }, 'problem_ac_rate': get_bar_chart( queryset.values('contest__problem__order', 'problem__name').annotate(ac_rate=ac_rate) .order_by('contest__problem__order').values_list('problem__name', 'ac_rate'), ), 'language_count': get_pie_chart( queryset.values('language__name').annotate(count=Count('language__name')) .filter(count__gt=0).order_by('-count').values_list('language__name', 'count'), ), 'language_ac_rate': get_bar_chart( queryset.values('language__name').annotate(ac_rate=ac_rate) .filter(ac_rate__gt=0).values_list('language__name', 'ac_rate'), ), } context['stats'] = mark_safe(json.dumps(stats)) return context ContestRankingProfile = namedtuple( 'ContestRankingProfile', 'id user css_class username points cumtime organization participation ' 'participation_rating problem_cells result_cell', ) BestSolutionData = namedtuple('BestSolutionData', 'code points time state is_pretested') def make_contest_ranking_profile(contest, participation, contest_problems): user = participation.user return ContestRankingProfile( id=user.id, user=user.user, css_class=user.css_class, username=user.username, points=participation.score, cumtime=participation.cumtime, organization=user.organization, participation_rating=participation.rating.rating if hasattr(participation, 'rating') else None, problem_cells=[contest.format.display_user_problem(participation, contest_problem) for contest_problem in contest_problems], result_cell=contest.format.display_participation_result(participation), participation=participation, ) def base_contest_ranking_list(contest, problems, queryset): return [make_contest_ranking_profile(contest, participation, problems) for participation in queryset.select_related('user__user', 'rating').defer('user__about', 'user__organizations__about')] def contest_ranking_list(contest, problems): return base_contest_ranking_list(contest, problems, contest.users.filter(virtual=0, user__is_unlisted=False) .prefetch_related('user__organizations') .extra(select={'round_score': 'round(score, 6)'}) .order_by('is_disqualified', '-round_score', 'cumtime')) def get_contest_ranking_list(request, contest, participation=None, ranking_list=contest_ranking_list, show_current_virtual=True, ranker=ranker): problems = list(contest.contest_problems.select_related('problem').defer('problem__description').order_by('order')) if contest.hide_scoreboard and contest.is_in_contest(request.user): return ([(_('???'), make_contest_ranking_profile(contest, request.profile.current_contest, problems))], problems) users = ranker(ranking_list(contest, problems), key=attrgetter('points', 'cumtime')) if show_current_virtual: if participation is None and request.user.is_authenticated: participation = request.profile.current_contest if participation is None or participation.contest_id != contest.id: participation = None if participation is not None and participation.virtual: users = chain([('-', make_contest_ranking_profile(contest, participation, problems))], users) return users, problems def contest_ranking_ajax(request, contest, participation=None): contest, exists = _find_contest(request, contest) if not exists: return HttpResponseBadRequest('Invalid contest', content_type='text/plain') if not contest.can_see_scoreboard(request.user): raise Http404() users, problems = get_contest_ranking_list(request, contest, participation) return render(request, 'contest/ranking-table.html', { 'users': users, 'problems': problems, 'contest': contest, 'has_rating': contest.ratings.exists(), }) class ContestRankingBase(ContestMixin, TitleMixin, DetailView): template_name = 'contest/ranking.html' tab = None def get_title(self): raise NotImplementedError() def get_content_title(self): return self.object.name def get_ranking_list(self): raise NotImplementedError() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) if not self.object.can_see_scoreboard(self.request.user): raise Http404() users, problems = self.get_ranking_list() context['users'] = users context['problems'] = problems context['last_msg'] = event.last() context['tab'] = self.tab return context class ContestRanking(ContestRankingBase): tab = 'ranking' def get_title(self): return _('%s Rankings') % self.object.name def get_ranking_list(self): return get_contest_ranking_list(self.request, self.object) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['has_rating'] = self.object.ratings.exists() return context class ContestParticipationList(LoginRequiredMixin, ContestRankingBase): tab = 'participation' def get_title(self): if self.profile == self.request.profile: return _('Your participation in %s') % self.object.name return _("%s's participation in %s") % (self.profile.username, self.object.name) def get_ranking_list(self): queryset = self.object.users.filter(user=self.profile, virtual__gte=0).order_by('-virtual') live_link = format_html('{0}', _('Live'), self.profile.username, reverse('contest_ranking', args=[self.object.key])) return get_contest_ranking_list( self.request, self.object, show_current_virtual=False, ranking_list=partial(base_contest_ranking_list, queryset=queryset), ranker=lambda users, key: ((user.participation.virtual or live_link, user) for user in users)) def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context['has_rating'] = False context['now'] = timezone.now() context['rank_header'] = _('Participation') return context def get(self, request, *args, **kwargs): if 'user' in kwargs: self.profile = get_object_or_404(Profile, user__username=kwargs['user']) else: self.profile = self.request.profile return super().get(request, *args, **kwargs) class ContestParticipationDisqualify(ContestMixin, SingleObjectMixin, View): def get_object(self, queryset=None): contest = super().get_object(queryset) if not contest.is_editable_by(self.request.user): raise Http404() return contest def post(self, request, *args, **kwargs): self.object = self.get_object() try: participation = self.object.users.get(pk=request.POST.get('participation')) except ObjectDoesNotExist: pass else: participation.set_disqualified(not participation.is_disqualified) return HttpResponseRedirect(reverse('contest_ranking', args=(self.object.key,))) class ContestMossMixin(ContestMixin, PermissionRequiredMixin): permission_required = 'judge.moss_contest' def get_object(self, queryset=None): contest = super().get_object(queryset) if settings.MOSS_API_KEY is None: raise Http404() if not contest.is_editable_by(self.request.user): raise Http404() return contest class ContestMossView(ContestMossMixin, TitleMixin, DetailView): template_name = 'contest/moss.html' def get_title(self): return _('%s MOSS Results') % self.object.name def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) problems = list(map(attrgetter('problem'), self.object.contest_problems.order_by('order') .select_related('problem'))) languages = list(map(itemgetter(0), ContestMoss.LANG_MAPPING)) results = ContestMoss.objects.filter(contest=self.object) moss_results = defaultdict(list) for result in results: moss_results[result.problem].append(result) for result_list in moss_results.values(): result_list.sort(key=lambda x: languages.index(x.language)) context['languages'] = languages context['has_results'] = results.exists() context['moss_results'] = [(problem, moss_results[problem]) for problem in problems] return context def post(self, request, *args, **kwargs): self.object = self.get_object() status = run_moss.delay(self.object.key) return redirect_to_task_status( status, message=_('Running MOSS for %s...') % (self.object.name,), redirect=reverse('contest_moss', args=(self.object.key,)), ) class ContestMossDelete(ContestMossMixin, SingleObjectMixin, View): def post(self, request, *args, **kwargs): self.object = self.get_object() ContestMoss.objects.filter(contest=self.object).delete() return HttpResponseRedirect(reverse('contest_moss', args=(self.object.key,))) class ContestTagDetailAjax(DetailView): model = ContestTag slug_field = slug_url_kwarg = 'name' context_object_name = 'tag' template_name = 'contest/tag-ajax.html' class ContestTagDetail(TitleMixin, ContestTagDetailAjax): template_name = 'contest/tag.html' def get_title(self): return _('Contest tag: %s') % self.object.name