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 EmailMessage 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').distinct() # 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. Případně to může být org.""" if not self.object.resitele.filter(osoba__user=self.request.user).exists() and not self.request.user.je_org: 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 })") EmailMessage( subject="Nové řešení k " + seznam_do_subjectu, body=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í? to=list(prijemci), ).send() return formularOKView(self.request, text='Řešení úspěšně odevzdáno')