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\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')