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.

460 lines
19 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 = get_object_or_404(m.Rocnik, 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().distinct()
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)
self.jen_neobodovane = jen_neobodovane
def get_queryset(self):
self.inicializuj_osy_tabulky()
qs = super().get_queryset()
if self.jen_neobodovane:
qs = qs.filter(body__isnull=True)
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')
# FIXME tohle je ošklivé, na špatném místě a pomalé. Ale moc mě štvalo, že musím hledat správná místa v tabulce.
self.problemy = self.problemy.filter(id__in=qs.values("problem__id"))
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):
2 years ago
""" Náhled na řešení. Editace je v :py:class:`EditReseniView`. """
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):
2 years ago
self.check_access()
ctx = super().get_context_data(**kw)
detaily_hodnoceni = self.aktualni_hodnoceni()
ctx["hodnoceni"] = detaily_hodnoceni
# Subject případného mailu (template neumí použitelně spojovat řetězce: https://stackoverflow.com/q/4386168)
ctx["predmetmailu"] = "Oprava řešení M&M "+self.reseni.problem.first().hlavni_problem.nazev
ctx["maily_vsech_resitelu"] = [y for x in self.reseni.resitele.all().values_list('osoba__email') for y in x]
2 years ago
return ctx
def get(self, request, *args, **kwargs):
"""
Oproti :py:class:`django.views.generic.detail.BaseDetailView`
kontroluje přístup pomocí :py:meth:`check_access`
"""
response = super().get(self, request, *args, **kwargs)
self.check_access()
return response
def check_access(self):
""" Řešitel musí být součástí řešení, jinak se na něj nemá co dívat. """
if not self.object.resitele.filter(osoba__user=self.request.user).exists():
raise PermissionDenied()
class EditReseniView(DetailReseniView):
""" Editace (hlavně hodnocení) řešení. """
def get_context_data(self, **kw):
ctx = super().get_context_data(**kw)
ctx['form'] = f.OhodnoceniReseniFormSet(initial=ctx["hodnoceni"])
ctx['poznamka_form'] = f.PoznamkaReseniForm(instance=self.reseni)
2 years ago
ctx['edit'] = True
return ctx
2 years ago
def check_access(self):
# Na orga máme nároky už v urls.py ale better safe then sorry
if not self.request.user.je_org:
raise PermissionDenied()
2 years ago
def hodnoceniReseniView(request, pk, *args, **kwargs):
reseni = get_object_or_404(m.Reseni, pk=pk)
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 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.resitele.add(*form.cleaned_data["resitele"])
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')