Web M&M
https://mam.matfyz.cz
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
449 lines
18 KiB
449 lines
18 KiB
from django.core.exceptions import PermissionDenied
|
|
from django.views.generic import ListView, DetailView, FormView
|
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
|
from django.core.mail import send_mail
|
|
from django.utils import timezone
|
|
from django.views.generic import ListView, DetailView, FormView, CreateView
|
|
from django.views.generic.list import MultipleObjectTemplateResponseMixin,MultipleObjectMixin
|
|
from django.views.generic.base import View
|
|
from django.shortcuts import redirect, get_object_or_404, render
|
|
from django.urls import reverse
|
|
from django.db import transaction
|
|
from django.db.models import Q
|
|
|
|
from dataclasses import dataclass
|
|
import datetime
|
|
from itertools import groupby
|
|
import logging
|
|
|
|
import seminar.models as m
|
|
from . import forms as f
|
|
from .forms import OdevzdavatkoTabulkaFiltrForm as FiltrForm
|
|
from seminar.utils import resi_v_rocniku
|
|
from seminar.views import formularOKView
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Co chceme?
|
|
# - "Tabulku" aktuální řešitelé x zveřejněné problémy, v buňkách počet řešení
|
|
# - TabulkaOdevzdanychReseniView
|
|
# - Detail konkrétního problému a řešitele -- přehled všech řešení odevzdaných k tomuto problému
|
|
# - ReseniProblemuView
|
|
# - Detail konkrétního řešení -- všechny soubory, datum, ...
|
|
# - DetailReseniView
|
|
# - Pro řešitele: přehled jejich odevzdaných řešení
|
|
# - PrehledOdevzdanychReseni
|
|
#
|
|
# Taky se může hodit:
|
|
# - Tabulka všech řešitelů x všech problémů?
|
|
|
|
@dataclass
|
|
class SouhrnReseni:
|
|
"""Dataclass reprezentující data o odevzdaných řešeních pro zobrazení v tabulce."""
|
|
pocet_reseni : int
|
|
posledni_odevzdani : datetime.datetime
|
|
body : float
|
|
|
|
|
|
class TabulkaOdevzdanychReseniView(ListView):
|
|
template_name = 'odevzdavatko/tabulka.html'
|
|
model = m.Hodnoceni
|
|
|
|
def inicializuj_osy_tabulky(self):
|
|
"""Vyrobí prvotní querysety pro sloupce a řádky, tj. seznam všech řešitelů a problémů"""
|
|
# FIXME: jméno metody není vypovídající...
|
|
# NOTE: Tenhle blok nemůže být přímo ve třídě, protože před vyrobením databáze neexistují ty objekty (?). TODO: Otestovat
|
|
# TODO: Prefetches, Select related, ...
|
|
self.resitele = m.Resitel.objects.all()
|
|
self.problemy = m.Problem.objects.all()
|
|
self.reseni = m.Reseni.objects.all()
|
|
|
|
self.aktualni_rocnik = m.Nastaveni.get_solo().aktualni_rocnik # .get_solo() vrátí tu jedinou instanci
|
|
if 'rocnik' in self.kwargs:
|
|
self.aktualni_rocnik = m.Rocnik.objects.get(rocnik=self.kwargs['rocnik'])
|
|
|
|
form = FiltrForm(self.request.GET, rocnik=self.aktualni_rocnik)
|
|
if form.is_valid():
|
|
fcd = form.cleaned_data
|
|
resitele = fcd["resitele"]
|
|
problemy = fcd["problemy"]
|
|
reseni_od = fcd["reseni_od"]
|
|
reseni_do = fcd["reseni_do"]
|
|
jen_neobodovane = fcd["neobodovane"]
|
|
else:
|
|
initial = FiltrForm.gen_initial(self.aktualni_rocnik)
|
|
resitele = initial['resitele']
|
|
problemy = initial['problemy']
|
|
reseni_od = initial['reseni_od'][0]
|
|
reseni_do = initial['reseni_do'][0]
|
|
jen_neobodovane = initial["neobodovane"]
|
|
|
|
|
|
# Chceme jen letošní problémy
|
|
self.problemy = self.problemy.filter(Q(Tema___rocnik=self.aktualni_rocnik) | Q(Uloha___cislo_zadani__rocnik = self.aktualni_rocnik) | Q(Clanek___cislo__rocnik = self.aktualni_rocnik) | Q(Konfera___soustredeni__rocnik = self.aktualni_rocnik))
|
|
|
|
self.chteni_resitele = resitele # Zapamatování pro get_context_data
|
|
if resitele == FiltrForm.RESITELE_RELEVANTNI:
|
|
# Nejde použít utils.resi_v_rocniku, protože noví řešitelé mohou mít neobodované řešení a takoví technicky zatím neřeší.
|
|
# Proto používám neodmaturovavší řešitele, TODO: Chceme to takhle nebo jinak?
|
|
self.resitele = self.resitele.filter(rok_maturity__gt=self.aktualni_rocnik.prvni_rok) # Prvotní sada, pokud nebude mít body, odstraní se v get_context_data
|
|
elif resitele == FiltrForm.RESITELE_NEODMATUROVAVSI:
|
|
self.resitele = self.resitele.filter(rok_maturity__gt=self.aktualni_rocnik.prvni_rok)
|
|
|
|
if problemy == FiltrForm.PROBLEMY_MOJE:
|
|
org = m.Organizator.objects.get(osoba__user=self.request.user)
|
|
self.problemy = self.problemy.filter(
|
|
Q(autor=org)|Q(garant=org)|Q(opravovatele=org),
|
|
Q(stav=m.Problem.STAV_ZADANY)|Q(stav=m.Problem.STAV_VYRESENY),
|
|
)
|
|
elif problemy == FiltrForm.PROBLEMY_LETOSNI:
|
|
self.problemy = self.problemy.filter(
|
|
Q(stav=m.Problem.STAV_ZADANY)|Q(stav=m.Problem.STAV_VYRESENY),
|
|
)
|
|
#self.problemy = list(filter(lambda problem: problem.rocnik() == self.aktualni_rocnik, self.problemy)) # DB HOG? # FIXME: některé problémy nemají ročník....
|
|
# NOTE: Protože řešení odkazuje přímo na Problém a QuerySet na Hodnocení je nepolymorfní, musíme porovnávat taky s nepolymorfními Problémy.
|
|
self.problemy = self.problemy.non_polymorphic()
|
|
|
|
self.reseni = self.reseni.filter(cas_doruceni__date__gt=reseni_od, cas_doruceni__date__lte=reseni_do)
|
|
if jen_neobodovane:
|
|
self.reseni = self.reseni.filter(hodnoceni__body__isnull=True)
|
|
|
|
def get_queryset(self):
|
|
self.inicializuj_osy_tabulky()
|
|
qs = super().get_queryset()
|
|
qs = qs.filter(problem__in=self.problemy, reseni__in=self.reseni, reseni__resitele__in=self.resitele).select_related('reseni', 'problem').prefetch_related('reseni__resitele__osoba')
|
|
return qs
|
|
|
|
def get_context_data(self, *args, **kwargs):
|
|
# self.resitele, self.reseni a self.problemy jsou již nastavené
|
|
|
|
ctx = super().get_context_data(*args, **kwargs)
|
|
ctx['problemy'] = self.problemy
|
|
ctx['resitele'] = self.resitele
|
|
tabulka = dict()
|
|
|
|
def pridej_reseni(problem, resitel, body, cas):
|
|
if problem not in tabulka:
|
|
tabulka[problem] = dict()
|
|
if resitel not in tabulka[problem]:
|
|
tabulka[problem][resitel] = SouhrnReseni(pocet_reseni=1, posledni_odevzdani=cas, body=body)
|
|
else:
|
|
tabulka[problem][resitel].posledni_odevzdani = max(tabulka[problem][resitel].posledni_odevzdani, cas)
|
|
# Zvětšení počtu bodů o aktuální počet, pokud se tam někde nevyskytuje None – pak je součet taky None ("Pozor, nezadané body")
|
|
tabulka[problem][resitel].body = tabulka[problem][resitel].body + body if body is not None and tabulka[problem][resitel].body is not None else None
|
|
tabulka[problem][resitel].pocet_reseni += 1
|
|
# Pro jednoduchost template si ještě poznamenáme ID problému a řešitele
|
|
tabulka[problem][resitel].problem_id = problem.id
|
|
tabulka[problem][resitel].resitel_id = resitel.id
|
|
|
|
for hodnoceni in self.get_queryset():
|
|
for resitel in hodnoceni.reseni.resitele.all():
|
|
pridej_reseni(hodnoceni.problem, resitel, hodnoceni.body, hodnoceni.reseni.cas_doruceni)
|
|
|
|
hodnoty = []
|
|
resitele_do_tabulky = []
|
|
for resitel in self.resitele:
|
|
dostal_body = False
|
|
resiteluv_radek = []
|
|
for problem in self.problemy:
|
|
if problem in tabulka and resitel in tabulka[problem]:
|
|
resiteluv_radek.append(tabulka[problem][resitel])
|
|
dostal_body = True
|
|
else:
|
|
resiteluv_radek.append(None)
|
|
if self.chteni_resitele != FiltrForm.RESITELE_RELEVANTNI or dostal_body:
|
|
hodnoty.append(resiteluv_radek)
|
|
resitele_do_tabulky.append(resitel)
|
|
ctx['radky'] = list(zip(resitele_do_tabulky, hodnoty))
|
|
ctx['filtr'] = FiltrForm(initial=self.request.GET, rocnik=self.aktualni_rocnik)
|
|
# Pro použití hacku na automatické {{form.media}} v template:
|
|
ctx['form'] = ctx['filtr']
|
|
# Pro maximum v přesměrovátku ročníků
|
|
ctx['aktualni_rocnik'] = m.Nastaveni.get_solo().aktualni_rocnik
|
|
if 'rocnik' in self.kwargs:
|
|
ctx['rocnik'] = self.kwargs['rocnik']
|
|
else:
|
|
ctx['rocnik'] = ctx['aktualni_rocnik'].rocnik
|
|
|
|
return ctx
|
|
|
|
# Velmi silně inspirováno zdrojáky, FIXME: Nedá se to udělat smysluplněji?
|
|
class ReseniProblemuView(MultipleObjectTemplateResponseMixin, MultipleObjectMixin, View):
|
|
model = m.Reseni
|
|
template_name = 'odevzdavatko/seznam.html'
|
|
|
|
def get_queryset(self):
|
|
qs = super().get_queryset()
|
|
resitel_id = self.kwargs['resitel']
|
|
if resitel_id is None:
|
|
raise ValueError("Nemám řešitele!")
|
|
problem_id = self.kwargs['problem']
|
|
if problem_id is None:
|
|
raise ValueError("Nemám problém! (To je problém!)")
|
|
|
|
resitel = m.Resitel.objects.get(id=resitel_id)
|
|
problem = m.Problem.objects.get(id=problem_id)
|
|
qs = qs.filter(
|
|
problem__in=[problem],
|
|
resitele__in=[resitel],
|
|
)
|
|
return qs
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
self.object_list = self.get_queryset()
|
|
if self.object_list.count() == 1:
|
|
jedine_reseni = self.object_list.first()
|
|
return redirect(reverse("odevzdavatko_detail_reseni", kwargs={"pk": jedine_reseni.id}))
|
|
context = self.get_context_data()
|
|
return self.render_to_response(context)
|
|
|
|
def get_context_data(self, *args, **kwargs):
|
|
ctx = super().get_context_data(*args, **kwargs)
|
|
# XXX: Předat groupby do template nejde: https://stackoverflow.com/questions/6906593/itertools-groupby-in-a-django-template
|
|
# Django má {% regroup %}, ale ten potřebuje, aby klíč byl atribut položky: https://docs.djangoproject.com/en/3.2/ref/templates/builtins/#regroup
|
|
# Takže rozbalíme groupby do slovníku klíč → seznam sami (dictionary comphrehension)
|
|
ctx['reseni_podle_deadlinu'] = {k: list(v) for k,v in groupby(ctx['object_list'], lambda r: r.deadline_reseni)}
|
|
|
|
# Pro sitetree:
|
|
ctx["resitel_id"] = self.kwargs['resitel']
|
|
ctx["problem_id"] = self.kwargs['problem']
|
|
return ctx
|
|
|
|
## XXX: https://docs.djangoproject.com/en/3.1/topics/class-based-views/mixins/#avoid-anything-more-complex
|
|
class DetailReseniView(DetailView):
|
|
model = m.Reseni
|
|
template_name = 'odevzdavatko/detail.html'
|
|
|
|
def aktualni_hodnoceni(self):
|
|
self.reseni = get_object_or_404(m.Reseni, id=self.kwargs['pk'])
|
|
result = [] # Slovníky s klíči problem, body, deadline_body -- initial data pro f.OhodnoceniReseniFormSet
|
|
for hodn in m.Hodnoceni.objects.filter(reseni=self.reseni):
|
|
result.append({
|
|
"problem": hodn.problem,
|
|
"body": hodn.body,
|
|
"deadline_body": hodn.deadline_body,
|
|
"feedback": hodn.feedback,
|
|
})
|
|
return result
|
|
|
|
def get_context_data(self, **kw):
|
|
ctx = super().get_context_data(**kw)
|
|
ctx['form'] = f.OhodnoceniReseniFormSet(
|
|
initial = self.aktualni_hodnoceni()
|
|
)
|
|
ctx['poznamka_form'] = f.PoznamkaReseniForm(instance=self.reseni)
|
|
return ctx
|
|
|
|
|
|
def hodnoceniReseniView(request, pk, *args, **kwargs):
|
|
reseni = get_object_or_404(m.Reseni, pk=pk)
|
|
template_name = 'odevzdavatko/detail.html'
|
|
form_class = f.OhodnoceniReseniFormSet
|
|
success_url = reverse('odevzdavatko_detail_reseni', kwargs={'pk': pk})
|
|
|
|
# FIXME: Použit initial i tady a nebastlit hodnocení tak nízkoúrovňově
|
|
# Also: https://docs.djangoproject.com/en/2.2/topics/forms/modelforms/#django.forms.ModelForm
|
|
formset = f.OhodnoceniReseniFormSet(request.POST)
|
|
poznamka_form = f.PoznamkaReseniForm(request.POST, instance=reseni)
|
|
# TODO: Napsat validaci formuláře a formsetu
|
|
if not (formset.is_valid() and poznamka_form.is_valid()):
|
|
raise ValueError(formset.errors, poznamka_form.errors)
|
|
|
|
with transaction.atomic():
|
|
# Poznámka je jednoduchá na zpracování:
|
|
poznamka_form.save()
|
|
|
|
# Smažeme všechna dosavadní hodnocení tohoto řešení
|
|
qs = m.Hodnoceni.objects.filter(reseni=reseni)
|
|
logger.info(f"Will delete {qs.count()} objects: {qs}")
|
|
qs.delete()
|
|
|
|
# Vyrobíme nová podle formsetu
|
|
for form in formset:
|
|
hodnoceni = m.Hodnoceni(
|
|
reseni=reseni,
|
|
**form.cleaned_data,
|
|
)
|
|
logger.info(f"Creating Hodnoceni: {hodnoceni}")
|
|
hodnoceni.save()
|
|
|
|
return redirect(success_url)
|
|
|
|
|
|
class ResitelReseniView(DetailView):
|
|
model = m.Reseni
|
|
template_name = 'odevzdavatko/detail_resitele.html'
|
|
|
|
def aktualni_hodnoceni(self):
|
|
self.reseni = get_object_or_404(m.Reseni, id=self.kwargs['pk'])
|
|
result = []
|
|
for hodn in m.Hodnoceni.objects.filter(reseni=self.reseni):
|
|
result.append(
|
|
{
|
|
"problem": hodn.problem,
|
|
"body": hodn.body,
|
|
"feedback": hodn.feedback,
|
|
# "deadline_body": hodn.deadline_body,
|
|
}
|
|
)
|
|
return result
|
|
|
|
def get_context_data(self, **kw):
|
|
ctx = super().get_context_data(**kw)
|
|
hodnoceni = self.aktualni_hodnoceni()
|
|
if not self.reseni.resitele.filter(osoba__user=self.request.user).exists():
|
|
raise PermissionDenied()
|
|
# ctx['poznamka'] = f.PoznamkaReseniForm(instance=self.reseni)
|
|
ctx["hodnoceni"] = hodnoceni
|
|
return ctx
|
|
|
|
|
|
|
|
class PrehledOdevzdanychReseni(ListView):
|
|
model = m.Hodnoceni
|
|
template_name = 'odevzdavatko/prehled_reseni.html'
|
|
|
|
def get_queryset(self):
|
|
if not self.request.user.is_authenticated:
|
|
raise RuntimeError("Uživatel měl být přihlášený!")
|
|
# get_or_none, aby neexistence řešitele (např. u orgů) neházela chybu
|
|
resitel = m.Resitel.objects.filter(osoba__user=self.request.user).first()
|
|
qs = super().get_queryset()
|
|
qs = qs.filter(reseni__resitele__in=[resitel])
|
|
# Setřídíme podle času doručení řešení, aby se netřídily podle okamžiku vyrobení Hodnocení
|
|
qs = qs.order_by('reseni__cas_doruceni')
|
|
return qs
|
|
|
|
def get_context_data(self, *args, **kwargs):
|
|
ctx = super().get_context_data(*args, **kwargs)
|
|
# Ročník určujeme podle čísla, do jehož deadlinu došlo řešení.
|
|
# Chceme to mít seřazené, takže místo comphrerehsion ručně postavíme pole polí. Django templates neumí použít OrderedDict :-/
|
|
podle_rocniku = []
|
|
for rocnik, hodnoceni in groupby(ctx['object_list'], lambda ho: ho.deadline_body.cislo.rocnik if ho.deadline_body is not None else None):
|
|
podle_rocniku.append((rocnik, list(hodnoceni)))
|
|
ctx['podle_rocniku'] = reversed(podle_rocniku) # Od nejnovějšího ročníku
|
|
# TODO: Umožnit stažení / zobrazení řešení
|
|
return ctx
|
|
|
|
# Přehled všech řešení kvůli debugování
|
|
|
|
class SeznamReseniView(ListView):
|
|
model = m.Reseni
|
|
template_name = 'odevzdavatko/seznam.html'
|
|
|
|
class SeznamAktualnichReseniView(SeznamReseniView):
|
|
def get_queryset(self):
|
|
qs = super().get_queryset()
|
|
akt_rocnik = m.Nastaveni.get_solo().aktualni_rocnik # .get_solo() vrátí tu jedinou instanci, asi...
|
|
resitele = resi_v_rocniku(akt_rocnik)
|
|
qs = qs.filter(resitele__in=resitele) # FIXME: Najde řešení i ze starých ročníků, která odevzdal alespoň jeden aktuální řešitel
|
|
return qs
|
|
|
|
|
|
class PosliReseniView(LoginRequiredMixin, FormView):
|
|
template_name = 'odevzdavatko/posli_reseni.html'
|
|
form_class = f.PosliReseniForm
|
|
|
|
def form_valid(self, form):
|
|
data = form.cleaned_data
|
|
nove_reseni = m.Reseni.objects.create(
|
|
cas_doruceni=data['cas_doruceni'],
|
|
forma=data['forma'],
|
|
poznamka=data['poznamka'],
|
|
)
|
|
nove_reseni.resitele.add(data['resitel'])
|
|
nove_reseni.problem.add(data['problem'])
|
|
nove_reseni.save()
|
|
|
|
context = self.get_context_data()
|
|
prilohy = context['prilohy']
|
|
prilohy.instance = nove_reseni
|
|
prilohy.save()
|
|
# Chtěl jsem, aby bylo vidět, že se to uložilo, tak přesměrovávám na profil.
|
|
return redirect(reverse('profil'))
|
|
|
|
|
|
def get_context_data(self,**kwargs):
|
|
data = super().get_context_data(**kwargs)
|
|
if self.request.POST:
|
|
data['prilohy'] = f.ReseniSPrilohamiFormSet(self.request.POST,self.request.FILES)
|
|
else:
|
|
data['prilohy'] = f.ReseniSPrilohamiFormSet()
|
|
return data
|
|
|
|
|
|
class NahrajReseniView(LoginRequiredMixin, CreateView):
|
|
model = m.Reseni
|
|
template_name = 'odevzdavatko/nahraj_reseni.html'
|
|
form_class = f.NahrajReseniForm
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
# Zaříznutí starých řešitelů:
|
|
# FIXME: Je to tady dost naprasené, mělo by to asi být jinde…
|
|
osoba = m.Osoba.objects.get(user=self.request.user)
|
|
resitel = osoba.resitel
|
|
if resitel.rok_maturity <= m.Nastaveni.get_solo().aktualni_rocnik.prvni_rok:
|
|
return render(request, 'universal.html', {
|
|
'title': 'Nelze odevzdat',
|
|
'error': 'Zdá se, že jsi již odmaturoval/a, a tedy nemůžeš odevzdat do našeho semináře řešení.',
|
|
'text': 'Pokud se ti zdá, že to je chyba, napiš nám prosím e-mail. Díky.',
|
|
})
|
|
return super().get(request, *args, **kwargs)
|
|
|
|
def get_context_data(self,**kwargs):
|
|
data = super().get_context_data(**kwargs)
|
|
if self.request.POST:
|
|
data['prilohy'] = f.ReseniSPrilohamiFormSet(self.request.POST,self.request.FILES)
|
|
else:
|
|
data['prilohy'] = f.ReseniSPrilohamiFormSet()
|
|
return data
|
|
|
|
# FIXME prepsat tak, aby form_valid se volalo jen tehdy, kdyz je form i formset validni
|
|
# Inspirace: https://stackoverflow.com/questions/41599809/using-a-django-filefield-in-an-inline-formset
|
|
def form_valid(self,form):
|
|
context = self.get_context_data()
|
|
prilohy = context['prilohy']
|
|
if not prilohy.is_valid():
|
|
return super().form_invalid(form)
|
|
with transaction.atomic():
|
|
self.object = form.save()
|
|
self.object.resitele.add(m.Resitel.objects.get(osoba__user = self.request.user))
|
|
self.object.cas_doruceni = timezone.now()
|
|
self.object.forma = m.Reseni.FORMA_UPLOAD
|
|
self.object.save()
|
|
|
|
prilohy.instance = self.object
|
|
prilohy.save()
|
|
|
|
for hodnoceni in self.object.hodnoceni_set.all():
|
|
hodnoceni.deadline_body = m.Deadline.objects.filter(deadline__gte=self.object.cas_doruceni).first()
|
|
hodnoceni.save()
|
|
|
|
# Pošleme mail opravovatelům a garantovi
|
|
# FIXME: Nechat spočítat databázi? Je to pár dotazů (pravděpodobně), takže to za to možná nestojí
|
|
prijemci = set()
|
|
problemy = []
|
|
for prob in form.cleaned_data['problem']:
|
|
prijemci.update(prob.opravovatele.all())
|
|
if prob.garant is not None:
|
|
prijemci.add(prob.garant)
|
|
problemy.append(prob)
|
|
# FIXME: Možná poslat mail i relevantním orgům nadproblémů?
|
|
if len(prijemci) < 1:
|
|
logger.warning(f"Pozor, neposílám e-mail nikomu. Problémy: {problemy}")
|
|
# FIXME: Víc informativní obsah mailů, možná vč. příloh?
|
|
prijemci = map(lambda it: it.osoba.email, prijemci)
|
|
|
|
resitel = m.Osoba.objects.get(user = self.request.user)
|
|
|
|
seznam = "problému " + str(problemy[0]) if len(problemy) == 1 else 'následujícím problémům:\n' + ', \n'.join(map(str, problemy))
|
|
seznam_do_subjectu = "problému " + str(problemy[0]) + ("" if len(problemy) == 1 else f" (a dalším { len(problemy) - 1 })")
|
|
|
|
send_mail(
|
|
subject="Nové řešení k " + seznam_do_subjectu,
|
|
message=f"Řešitel{ '' if resitel.pohlavi_muz else 'ka' } { resitel } právě nahrál{'' if resitel.pohlavi_muz else 'a' } nové řešení k { seznam }.\n\nHurá do opravování: { self.object.absolute_url() }",
|
|
from_email="submitovatko@mam.mff.cuni.cz", # FIXME: Chceme to mít radši tady, nebo v nastavení?
|
|
recipient_list=list(prijemci),
|
|
)
|
|
|
|
return formularOKView(self.request, text='Řešení úspěšně odevzdáno')
|
|
|