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 decimal import Decimal from itertools import groupby import logging from . import forms as f from .forms import OdevzdavatkoTabulkaFiltrForm as FiltrForm from .models import Hodnoceni, Reseni from personalni.models import Resitel, Osoba, Organizator from tvorba.models import Problem, Deadline, Rocnik from tvorba.utils import resi_v_rocniku from various.models import Nastaveni from various.views.pomocne 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ů? class TabulkaOdevzdanychReseniView(ListView): template_name = 'odevzdavatko/tabulka.html' model = 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 = Resitel.objects.all() self.problemy = Problem.objects.all() self.reseni = Reseni.objects.all() self.aktualni_rocnik = Nastaveni.get_solo().aktualni_rocnik # .get_solo() vrátí tu jedinou instanci if 'rocnik' in self.kwargs: self.aktualni_rocnik = get_object_or_404(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"] self.barvicky = fcd["barvicky"] 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"] self.barvicky = initial["barvicky"] # 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 = Organizator.objects.get(osoba__user=self.request.user) self.problemy = self.problemy.filter( Q(autor=org)|Q(garant=org)|Q(opravovatele=org), Q(stav=Problem.STAV_ZADANY)|Q(stav=Problem.STAV_VYRESENY), ) elif problemy == FiltrForm.PROBLEMY_LETOSNI: self.problemy = self.problemy.filter( Q(stav=Problem.STAV_ZADANY)|Q(stav=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): # TODO: refactor asi. Přepisoval jsem to jen syntakticky, nejspíš půlka kódu přestala dávat smysl… # 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[Problem, dict[Resitel, list[tuple[Reseni, Hodnoceni]]]] = dict() soucty: dict[Problem, dict[Resitel, Decimal]] = dict() def pridej_reseni(resitel, hodnoceni): problem = hodnoceni.problem body = hodnoceni.body cas = hodnoceni.reseni.cas_doruceni reseni = hodnoceni.reseni if problem not in tabulka: tabulka[problem] = dict() soucty[problem] = dict() if resitel not in tabulka[problem]: tabulka[problem][resitel] = [(reseni, hodnoceni)] soucty[problem][resitel] = hodnoceni.body or 0 # Neobodované neřešíme else: tabulka[problem][resitel].append((reseni, hodnoceni)) soucty[problem][resitel] += hodnoceni.body or 0 # Neobodované neřešíme for hodnoceni in self.get_queryset(): for resitel in hodnoceni.reseni.resitele.all(): pridej_reseni(resitel, hodnoceni) hodnoty: list[list[tuple[Decimal,list[tuple[Reseni, Hodnoceni]]]]] = [] # Seznam řádků výsledné tabulky podle self.resitele, v každém řádku buňky v pořadí podle self.problemy + jejich součty, v každé buňce seznam řešení k danému řešiteli a problému. resitele_do_tabulky: list[Resitel] = [] for resitel in self.resitele: dostal_body = False resiteluv_radek: list[tuple[Decimal,list[tuple[Reseni, Hodnoceni]]]] = [] # podle pořadí v self.problemy for problem in self.problemy: if problem in tabulka and resitel in tabulka[problem]: resiteluv_radek.append((soucty[problem][resitel], tabulka[problem][resitel])) dostal_body = True else: resiteluv_radek.append((Decimal(0),[])) 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'] = Nastaveni.get_solo().aktualni_rocnik ctx['barvicky'] = self.barvicky 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): """Rozskok mezi více řešeními téhož problému od téhož řešitele. Asi už bude zastaralý v okamžiku, kdy se tenhle komentář nasadí na produkci :-) V případě, že takové řešení existuje jen jedno, tak na něj přesměruje.""" model = 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 = Resitel.objects.get(id=resitel_id) problem = 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 HODNOCENI_INITIAL_DATA = [ "problem", "body", "body_celkem", "body_neprepocitane", "body_neprepocitane_celkem", "body_max", "body_neprepocitane_max", "deadline_body", "feedback", ] ## 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 = Reseni template_name = 'odevzdavatko/detail.html' def aktualni_hodnoceni(self): self.reseni = get_object_or_404(Reseni, id=self.kwargs['pk']) result = [] # Slovníky s klíči problem, body, deadline_body -- initial data pro f.OhodnoceniReseniFormSet for hodn in Hodnoceni.objects.filter(reseni=self.reseni): result.append({attr: getattr(hodn, attr) for attr in HODNOCENI_INITIAL_DATA}) 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(Reseni, pk=pk) success_url = reverse('odevzdavatko_detail_reseni', kwargs={'pk': pk}) formset = f.OhodnoceniReseniFormSet(request.POST, initial=[ {k: getattr(h, k) for k in HODNOCENI_INITIAL_DATA} for h in Hodnoceni.objects.filter(reseni=reseni) ]) 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 = Hodnoceni.objects.filter(reseni=reseni) logger.info(f"Will delete {qs.count()} objects: {qs}") qs.delete() # Vyrobíme nová podle formsetu notifikace = False for form in formset: notifikace |= 'feedback' in form.changed_data data_for_hodnoceni = form.cleaned_data data_for_body = data_for_hodnoceni.copy() del(data_for_hodnoceni["body_celkem"]) del(data_for_hodnoceni["body_neprepocitane"]) del(data_for_hodnoceni["body_neprepocitane_celkem"]) hodnoceni = Hodnoceni( reseni=reseni, **form.cleaned_data, ) logger.info(f"Creating Hodnoceni: {hodnoceni}") zmeny_bodu = [it for it in form.changed_data if it.startswith("body")] if len(zmeny_bodu) == 1: hodnoceni.__setattr__(zmeny_bodu[0], data_for_body[zmeny_bodu[0]]) # > jedna změna je špatně, ale 4 "změny" znamenají že nebylo nic zadáno if len(zmeny_bodu) > 1 and len(zmeny_bodu) != 4 and len(zmeny_bodu) != 2: # 4 znamená vše už vyplněno a nic nezměněno, 2 znamená předvyplnili se součty a nic se nezměnilo logger.warning(f"Hodnocení {hodnoceni} mělo mít nastavené víc různých bodů: {zmeny_bodu}. Nastavuji -0.1.") hodnoceni.body = -0.1 hodnoceni.save() adresati = reseni.resitele.filter(upozorneni=True).values_list('osoba__email', flat=True) if notifikace and adresati: email = EmailMessage( subject='Změna hodnocení odevzdaného řešení', body=f"""Milá řešitelko, milý řešiteli, došlo ke změně zpětné vazby k Tebou odevzdanému řešení. Zobrazit si ji můžeš na {reseni.resitel_url()}. Tvoji organizátoři M&M --- Nechceš-li tato upozornění dostávat, můžeš si to nastavit ve svém profilu.""", from_email='odevzdavatko@mam.mff.cuni.cz', bcc=adresati, ) email.send() return redirect(success_url) class PrehledOdevzdanychReseni(ListView): model = 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 = 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): suma_bodu = 0 hodnoceni = list(hodnoceni) for i in hodnoceni : if i.body != None : suma_bodu += i.body podle_rocniku.append((rocnik, hodnoceni, suma_bodu)) 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 = Reseni template_name = 'odevzdavatko/seznam.html' class SeznamAktualnichReseniView(SeznamReseniView): def get_queryset(self): qs = super().get_queryset() akt_rocnik = 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 VlozReseniView(LoginRequiredMixin, FormView): template_name = 'odevzdavatko/vloz_reseni.html' form_class = f.PosliReseniForm def form_valid(self, form): data = form.cleaned_data nove_reseni = 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 NahrajReseniRozcestnikTematekView(LoginRequiredMixin, ListView): model = Problem template_name = 'odevzdavatko/nahraj_reseni_nadproblem.html' def get_queryset(self): return super().get_queryset().filter(stav=Problem.STAV_ZADANY, nadproblem__isnull=True) class NahrajReseniView(LoginRequiredMixin, CreateView): model = Reseni template_name = 'odevzdavatko/nahraj_reseni.html' form_class = f.NahrajReseniForm nadproblem: Problem def setup(self, request, *args, **kwargs): super().setup(request, *args, **kwargs) nadproblem_id = self.kwargs["nadproblem_id"] self.nadproblem = get_object_or_404(Problem, id=nadproblem_id) def get(self, request, *args, **kwargs): # Zaříznutí nezadaných problémů if self.nadproblem.stav != Problem.STAV_ZADANY: raise PermissionDenied() # Zaříznutí starých řešitelů: # FIXME: Je to tady dost naprasené, mělo by to asi být jinde… osoba = Osoba.objects.get(user=self.request.user) resitel = osoba.resitel if resitel.rok_maturity <= 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_initial(self): nadproblem_id = self.nadproblem.id return { "nadproblem_id": nadproblem_id, "problem": [] if self.nadproblem.podproblem.filter(stav=Problem.STAV_ZADANY).exists() else nadproblem_id } 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() data["nadproblem_id"] = self.nadproblem.id data["nadproblem"] = get_object_or_404(Problem, id=self.nadproblem.id) 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(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 = Reseni.FORMA_UPLOAD self.object.save() prilohy.instance = self.object prilohy.save() for hodnoceni in self.object.hodnoceni_set.all(): hodnoceni.deadline_body = 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 = 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"{resitel} posílá 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', dalsi_odkazy=[("Odevzdat další řešení", reverse("odevzdavatko_nahraj_reseni"))], )