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 ) :
""" 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 ) :
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 ]
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 )
ctx [ ' edit ' ] = True
return ctx
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 ( )
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 \n Hurá 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 ' )