diff --git a/mamweb/settings_common.py b/mamweb/settings_common.py index 3c850245..08997bb4 100644 --- a/mamweb/settings_common.py +++ b/mamweb/settings_common.py @@ -138,7 +138,7 @@ INSTALLED_APPS = ( 'various.autentizace', 'api', 'aesop', - + 'odevzdavatko', # Admin upravy: # 'material', diff --git a/mamweb/urls.py b/mamweb/urls.py index 2fdbff45..b47be1cc 100644 --- a/mamweb/urls.py +++ b/mamweb/urls.py @@ -16,6 +16,9 @@ urlpatterns = [ # Seminarova aplikace (ma vlastni podadresare) path('', include('seminar.urls')), + + # Odevzdavatko (ma vlastni podadresare) + path('', include('odevzdavatko.urls')), # Korekturovaci aplikace (ma vlastni podadresare) path('', include('korektury.urls')), diff --git a/odevzdavatko/__init__.py b/odevzdavatko/__init__.py new file mode 100644 index 00000000..a4ee2679 --- /dev/null +++ b/odevzdavatko/__init__.py @@ -0,0 +1,11 @@ +""" +Obsahuje vše, co se týká odevzdávání (+ nahrávání) a opravování řešení řešitelů. + +Slovníček: + Moje řešení = Přehled řešení = Řešení, která odevzdal aktuálního uživatel sám. + Došlá řešení = Tabulka + seznam + detail + ... = Řešení, která poslal někdo jiný. + Poslat řešení = Odevdat mé řešení. (Tj. řešení se vztahem k aktuálnímu uživateli.) + Nahrát řešení = Nahrání řešení bez vztahu k aktuálnímu uživateli. + +TODO: Místo vložit řešení v nahrávání a posílání řešení dát něco jiného? +""" \ No newline at end of file diff --git a/odevzdavatko/admin.py b/odevzdavatko/admin.py new file mode 100644 index 00000000..6048eb36 --- /dev/null +++ b/odevzdavatko/admin.py @@ -0,0 +1,28 @@ +from django.contrib import admin +from django_reverse_admin import ReverseModelAdmin +import seminar.models as m + + +class PrilohaReseniInline(admin.TabularInline): + model = m.PrilohaReseni + extra = 1 + + +class Reseni_ResiteleInline(admin.TabularInline): + model = m.Reseni_Resitele + + +@admin.register(m.Reseni) +class ReseniAdmin(ReverseModelAdmin): + base_model = m.Reseni + inline_type = 'tabular' + # inline_reverse = ['text_cely','resitele'] TODO vrátit zpět a zrychlit dotaz + inline_reverse = ['resitele'] + exclude = ['text_zkraceny', 'text_zkraceny_set'] + inlines = [PrilohaReseniInline] +# FAIL in template +# inlines = [PrilohaReseniInline,Reseni_ResiteleInline] + + +admin.site.register(m.PrilohaReseni) +admin.site.register(m.Hodnoceni) diff --git a/odevzdavatko/apps.py b/odevzdavatko/apps.py new file mode 100644 index 00000000..507c284f --- /dev/null +++ b/odevzdavatko/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class OdevzdavatkoConfig(AppConfig): + name = 'odevzdavatko' diff --git a/odevzdavatko/forms.py b/odevzdavatko/forms.py new file mode 100644 index 00000000..14639b65 --- /dev/null +++ b/odevzdavatko/forms.py @@ -0,0 +1,218 @@ +from django import forms +from dal import autocomplete +from django.forms import formset_factory +from django.forms.models import inlineformset_factory + +from seminar.models import Resitel +import seminar.models as m + +import logging + +# pro přidání políčka do formuláře je potřeba +# - mít v modelu tu položku, kterou chci upravovat +# - přidat do views (prihlaskaView, resitelEditView) +# - přidat do forms +# - includovat do html + +class DateInput(forms.DateInput): + # aby se datum dalo vybírat z kalendáře + input_type = 'date' + + +class PosliReseniForm(forms.Form): + #FIXME jen podproblémy daného problému + problem = forms.ModelChoiceField(label='Problém',queryset=m.Problem.objects.all()) + # to_field_name + #problem = models.ManyToManyField(Problem, verbose_name='problém', help_text='Problém', + # through='Hodnoceni') + + # FIXME pridat vice resitelu + resitel = forms.ModelChoiceField(label="Řešitel", + queryset=Resitel.objects.all(), + widget=autocomplete.ModelSelect2( + url='autocomplete_resitel', + attrs = {'data-placeholder--id': '-1', + 'data-placeholder--text' : '---', + 'data-allow-clear': 'true'}) + ) + + + #resitele = models.ManyToManyField(Resitel, verbose_name='autoři řešení', + # help_text='Seznam autorů řešení', through='Reseni_Resitele') + + cas_doruceni = forms.DateField(widget=DateInput(),label="Čas doručení") + + #cas_doruceni = models.DateTimeField('čas_doručení', default=timezone.now, blank=True) + + forma = forms.ChoiceField(label="Forma řešení",choices = m.Reseni.FORMA_CHOICES) + #forma = models.CharField('forma řešení', max_length=16, choices=FORMA_CHOICES, blank=False, + # default=FORMA_EMAIL) + + poznamka = forms.CharField(label='Neveřejná poznámka', required=False) + #poznamka = models.TextField('neveřejná poznámka', blank=True, + # help_text='Neveřejná poznámka k řešení (plain text)') + + #TODO body do cisla + #TODO prilohy + + ##def __init__(self, *args, **kwargs): + ## super().__init__(*args, **kwargs) + ## #self.fields['favorite_color'] = forms.ChoiceField(choices=[(color.id, color.name) for color in Resitel.objects.all()]) + +class NahrajReseniForm(forms.ModelForm): + class Meta: + model = m.Reseni + fields = ('problem',) + help_texts = {'problem':''} # Nezobrazovat help text ve formuláři + + widgets = {'problem': + autocomplete.ModelSelect2Multiple( + url='autocomplete_problem_odevzdatelny', + attrs = {'data-placeholder--id': '-1', + 'data-placeholder--text' : '---', + 'data-allow-clear': 'true'}, + ) + } + +ReseniSPrilohamiFormSet = inlineformset_factory(m.Reseni,m.PrilohaReseni, + form = NahrajReseniForm, + fields = ('soubor','res_poznamka'), + widgets = {'res_poznamka':forms.TextInput()}, + extra = 1, + can_delete = False, + + ) + + +class JednoHodnoceniForm(forms.ModelForm): + class Meta: + model = m.Hodnoceni + fields = ('problem', 'body', 'cislo_body') + widgets = { + 'problem': autocomplete.ModelSelect2( + url='autocomplete_problem_odevzdatelny', # FIXME: Dovolit i starší? + ) + } + +OhodnoceniReseniFormSet = formset_factory(JednoHodnoceniForm, + extra = 0, + ) + +class PoznamkaReseniForm(forms.ModelForm): + class Meta: + model = m.Reseni + fields = ('poznamka',) + +# FIXME: Ideálně by mělo být součástí třídy níž, ale neumím to udělat +DATE_FORMAT = '%Y-%m-%d' + +class OdevzdavatkoTabulkaFiltrForm(forms.Form): + """Form pro filtrování přehledové odevzdávátkové tabulky + + Inspirováno https://kam.mff.cuni.cz/mffzoom/""" + + # Věci definované níž se importují i ve views pro odevzdávátko (Inspirováno https://docs.djangoproject.com/en/3.1/ref/models/fields/#field-choices) + + RESITELE_RELEVANTNI = 'relevantni' + RESITELE_NEODMATUROVAVSI = 'neodmaturovavsi' + RESITELE_CHOICES = [ + (RESITELE_RELEVANTNI, 'Relevantní řešitelé'), # I.e. nezobrazovat prázdné řádky tabulky + (RESITELE_NEODMATUROVAVSI, 'Všichni bez maturity'), + # Možná: všechny vč. historických? + ] + + PROBLEMY_MOJE = 'moje' + PROBLEMY_LETOSNI = 'letosni' + PROBLEMY_CHOICES = [ + (PROBLEMY_MOJE, 'Moje problémy'), # Letošní problémy, které mají v sobě nebo v nadproblémech přiřazeného daného orga + (PROBLEMY_LETOSNI, 'Všechny letošní'), + # TODO: *hlavní problémy, možná všechny... + # XXX: Chtělo by to i "aktuálně zadané... + ] + + # TODO: Typy problémů (problémy, úlohy, ostatní, všechny)? Jen některá řešení (obodovaná/neobodovaná, víc řešitelů, ...)? + + + @classmethod + def gen_terminy(cls, rocnik=None): + import datetime + from time import strftime + + from django.db.utils import OperationalError + try: + aktualni_rocnik = m.Nastaveni.get_solo().aktualni_rocnik + aktualni_cislo = m.Nastaveni.get_solo().aktualni_cislo + except OperationalError: + # django.db.utils.OperationalError: no such table: seminar_nastaveni + # Nemáme databázi, takže to selhalo. Pro jistotu vrátíme aspoň dvě možnosti, ať to nepadá dál + logger = logging.getLogger(__name__) + logger.error("Rozbitá databáze (před počátečními migracemi?)") + return [('broken', 'Je to rozbitý'), ('fubar', 'Nefunguje to')] + + # FIXME: Tohle je hnusný monkey patch, mělo by to být nějak zahrnuto výš. + if rocnik is not None: + aktualni_rocnik = rocnik + aktualni_cislo = m.Cislo.objects.filter(rocnik=rocnik).order_by('poradi').last() + + result = [] + + for cislo in m.Cislo.objects.filter( + rocnik=aktualni_rocnik, + poradi__lte=aktualni_cislo.poradi, + ).reverse(): # Standardně se řadí od nejnovějšího čísla + # Předem je mi líto kohokoliv, kdo tyhle řádky bude číst... + if cislo.datum_vydani is not None and cislo.datum_vydani <= datetime.date.today(): + result.append(( + strftime(DATE_FORMAT, cislo.datum_vydani.timetuple()), + f"Vydání {cislo.poradi}. čísla")) + if cislo.datum_preddeadline is not None and cislo.datum_preddeadline <= datetime.date.today(): + result.append(( + strftime(DATE_FORMAT, cislo.datum_preddeadline.timetuple()), + f"Předdeadline {cislo.poradi}. čísla")) + if cislo.datum_deadline_soustredeni is not None and cislo.datum_deadline_soustredeni <= datetime.date.today(): + result.append(( + strftime(DATE_FORMAT, cislo.datum_deadline_soustredeni.timetuple()), + f"Sous. deadline {cislo.poradi}. čísla")) + if cislo.datum_deadline is not None and cislo.datum_deadline <= datetime.date.today(): + result.append(( + strftime(DATE_FORMAT, cislo.datum_deadline.timetuple()), + f"Finální deadline {cislo.poradi}. čísla")) + result.append(( + strftime(DATE_FORMAT, datetime.date.today().timetuple()), f"Dnes")) + + return result + + @classmethod + def gen_initial(cls, rocnik=None): + terminy = cls.gen_terminy(rocnik) + initial = { + 'resitele': cls.RESITELE_RELEVANTNI, + 'problemy': cls.PROBLEMY_MOJE, + # Pokud chceme neaktuální ročník, tak nás nejspíš zajímají všechna řešení… + 'reseni_od': terminy[-2] if rocnik is None else terminy[0], + 'reseni_do': terminy[-1], + 'neobodovane': False, + } + return initial + + def __init__(self, *args, rocnik=None, **kwargs): + if 'initial' not in kwargs: + super().__init__(initial=self.gen_initial(rocnik), *args, **kwargs) + else: + super().__init__(*args, **kwargs) + # choices jako parametr Select widgetu neumí brát callable, jen iterable, takže si pro jednoduchost můžu rovnou uložit výsledek sem... + # A "sem" znamená do libovolné metody, protože jinak se jedná o kód, který django spustí při inicializaci a protože potřebujeme databázi, tak by spadnul při vyrábění testdat... + self.terminy = self.gen_terminy(rocnik) + self.fields['reseni_od'].widget = forms.Select(choices=self.gen_terminy(rocnik)) + # Pokud chceme neaktuální ročník, tak nás nejspíš zajímají všechna řešení… + self.fields['reseni_od'].initial = self.terminy[-2] if rocnik is None else self.terminy[0] + self.fields['reseni_do'].widget = forms.Select(choices=self.gen_terminy(rocnik)) + self.fields['reseni_do'].initial = self.terminy[-1] + + # NOTE: Initial definuji pro jednotlivé fieldy, aby to bylo tady a nebylo potřeba to řešit ve views... + resitele = forms.ChoiceField(choices=RESITELE_CHOICES) + problemy = forms.ChoiceField(choices=PROBLEMY_CHOICES) + + reseni_od = forms.DateField(input_formats=[DATE_FORMAT]) + reseni_do = forms.DateField(input_formats=[DATE_FORMAT]) + neobodovane = forms.BooleanField(required=False) diff --git a/odevzdavatko/migrations/__init__.py b/odevzdavatko/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/seminar/static/seminar/cross.png b/odevzdavatko/static/odevzdavatko/cross.png similarity index 100% rename from seminar/static/seminar/cross.png rename to odevzdavatko/static/odevzdavatko/cross.png diff --git a/seminar/static/seminar/dynamic_formsets.js b/odevzdavatko/static/odevzdavatko/dynamic_formsets.js similarity index 100% rename from seminar/static/seminar/dynamic_formsets.js rename to odevzdavatko/static/odevzdavatko/dynamic_formsets.js diff --git a/seminar/static/seminar/plus.png b/odevzdavatko/static/odevzdavatko/plus.png similarity index 100% rename from seminar/static/seminar/plus.png rename to odevzdavatko/static/odevzdavatko/plus.png diff --git a/seminar/templates/seminar/odevzdavatko/detail.html b/odevzdavatko/templates/odevzdavatko/detail.html similarity index 92% rename from seminar/templates/seminar/odevzdavatko/detail.html rename to odevzdavatko/templates/odevzdavatko/detail.html index 04e8c550..35f5c8c5 100644 --- a/seminar/templates/seminar/odevzdavatko/detail.html +++ b/odevzdavatko/templates/odevzdavatko/detail.html @@ -4,7 +4,7 @@ {% block content %} -{# FIXME: Necopypastovat! Tohle je zkopírované ze static/seminar/dynamic_formsets.js #} +{# FIXME: Necopypastovat! Tohle je zkopírované ze static/odevzdavatko/dynamic_formsets.js #} + {% endblock %} {% block content %} diff --git a/seminar/templates/seminar/org/vloz_reseni.html b/odevzdavatko/templates/odevzdavatko/posli_reseni.html similarity index 88% rename from seminar/templates/seminar/org/vloz_reseni.html rename to odevzdavatko/templates/odevzdavatko/posli_reseni.html index fa537cd9..2a6a2566 100644 --- a/seminar/templates/seminar/org/vloz_reseni.html +++ b/odevzdavatko/templates/odevzdavatko/posli_reseni.html @@ -3,7 +3,6 @@ {% block script %} {{form.media}} - {% endblock %} {% block content %} diff --git a/seminar/templates/seminar/odevzdavatko/resitel_prehled.html b/odevzdavatko/templates/odevzdavatko/prehled_reseni.html similarity index 100% rename from seminar/templates/seminar/odevzdavatko/resitel_prehled.html rename to odevzdavatko/templates/odevzdavatko/prehled_reseni.html diff --git a/seminar/templates/seminar/odevzdavatko/seznam.html b/odevzdavatko/templates/odevzdavatko/seznam.html similarity index 100% rename from seminar/templates/seminar/odevzdavatko/seznam.html rename to odevzdavatko/templates/odevzdavatko/seznam.html diff --git a/seminar/templates/seminar/odevzdavatko/tabulka.html b/odevzdavatko/templates/odevzdavatko/tabulka.html similarity index 97% rename from seminar/templates/seminar/odevzdavatko/tabulka.html rename to odevzdavatko/templates/odevzdavatko/tabulka.html index a5e3c350..f7a00a76 100644 --- a/seminar/templates/seminar/odevzdavatko/tabulka.html +++ b/odevzdavatko/templates/odevzdavatko/tabulka.html @@ -4,7 +4,7 @@ {% block content %} -
+ {{ filtr.resitele }} {{ filtr.problemy }} Od: {{ filtr.reseni_od }} diff --git a/odevzdavatko/urls.py b/odevzdavatko/urls.py new file mode 100644 index 00000000..477beb2b --- /dev/null +++ b/odevzdavatko/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from seminar.utils import org_required, resitel_required, viewMethodSwitch, \ + resitel_or_org_required +from . import views + +urlpatterns = [ + path('org/add_solution', org_required(views.PosliReseniView.as_view()), name='seminar_vloz_reseni'), + path('resitel/nahraj_reseni', resitel_required(views.NahrajReseniView.as_view()), name='seminar_nahraj_reseni'), + path('resitel/odevzdana_reseni/', resitel_or_org_required(views.PrehledOdevzdanychReseni.as_view()), name='seminar_resitel_odevzdana_reseni'), + + path('org/reseni/', org_required(views.TabulkaOdevzdanychReseniView.as_view()), name='odevzdavatko_tabulka'), + path('org/reseni/rocnik//', org_required(views.TabulkaOdevzdanychReseniView.as_view()), name='odevzdavatko_tabulka'), + path('org/reseni///', org_required(views.ReseniProblemuView.as_view()), name='odevzdavatko_reseni_resitele_k_problemu'), + path('org/reseni/', org_required(viewMethodSwitch(get=views.DetailReseniView.as_view(), post=views.hodnoceniReseniView)), name='odevzdavatko_detail_reseni'), + path('org/reseni/all', org_required(views.SeznamReseniView.as_view())), + path('org/reseni/akt', org_required(views.SeznamAktualnichReseniView.as_view())), +] diff --git a/seminar/views/odevzdavatko.py b/odevzdavatko/views.py similarity index 73% rename from seminar/views/odevzdavatko.py rename to odevzdavatko/views.py index ce86958c..7d38a25a 100644 --- a/seminar/views/odevzdavatko.py +++ b/odevzdavatko/views.py @@ -1,8 +1,10 @@ -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.views.generic.detail import SingleObjectMixin -from django.shortcuts import redirect, get_object_or_404 +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 @@ -13,9 +15,10 @@ from itertools import groupby import logging import seminar.models as m -import seminar.forms as f -from seminar.forms import OdevzdavatkoTabulkaFiltrForm as FiltrForm -from seminar.utils import aktivniResitele, resi_v_rocniku, deadline +from . import forms as f +from .forms import OdevzdavatkoTabulkaFiltrForm as FiltrForm +from seminar.utils import resi_v_rocniku, deadline +from seminar.views import formularOKView logger = logging.getLogger(__name__) @@ -41,7 +44,7 @@ class SouhrnReseni: class TabulkaOdevzdanychReseniView(ListView): - template_name = 'seminar/odevzdavatko/tabulka.html' + template_name = 'odevzdavatko/tabulka.html' model = m.Hodnoceni def inicializuj_osy_tabulky(self): @@ -161,7 +164,7 @@ class TabulkaOdevzdanychReseniView(ListView): # Velmi silně inspirováno zdrojáky, FIXME: Nedá se to udělat smysluplněji? class ReseniProblemuView(MultipleObjectTemplateResponseMixin, MultipleObjectMixin, View): model = m.Reseni - template_name = 'seminar/odevzdavatko/seznam.html' + template_name = 'odevzdavatko/seznam.html' def get_queryset(self): qs = super().get_queryset() @@ -203,7 +206,7 @@ class ReseniProblemuView(MultipleObjectTemplateResponseMixin, MultipleObjectMixi ## XXX: https://docs.djangoproject.com/en/3.1/topics/class-based-views/mixins/#avoid-anything-more-complex class DetailReseniView(DetailView): model = m.Reseni - template_name = 'seminar/odevzdavatko/detail.html' + template_name = 'odevzdavatko/detail.html' def aktualni_hodnoceni(self): self.reseni = get_object_or_404(m.Reseni, id=self.kwargs['pk']) @@ -227,7 +230,7 @@ class DetailReseniView(DetailView): def hodnoceniReseniView(request, pk, *args, **kwargs): reseni = get_object_or_404(m.Reseni, pk=pk) - template_name = 'seminar/odevzdavatko/detail.html' + template_name = 'odevzdavatko/detail.html' form_class = f.OhodnoceniReseniFormSet success_url = reverse('odevzdavatko_detail_reseni', kwargs={'pk': pk}) @@ -266,7 +269,7 @@ def hodnoceniReseniView(request, pk, *args, **kwargs): class PrehledOdevzdanychReseni(ListView): model = m.Hodnoceni - template_name = 'seminar/odevzdavatko/resitel_prehled.html' + template_name = 'odevzdavatko/prehled_reseni.html' def get_queryset(self): if not self.request.user.is_authenticated: @@ -292,7 +295,7 @@ class PrehledOdevzdanychReseni(ListView): class SeznamReseniView(ListView): model = m.Reseni - template_name = 'seminar/odevzdavatko/seznam.html' + template_name = 'odevzdavatko/seznam.html' class SeznamAktualnichReseniView(SeznamReseniView): def get_queryset(self): @@ -301,3 +304,94 @@ class SeznamAktualnichReseniView(SeznamReseniView): 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() + # Chtěl jsem, aby bylo vidět, že se to uložilo, tak přesměrovávám na profil. + return redirect(reverse('profil')) + + +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.cas_doruceni = timezone.now() + self.object.forma = m.Reseni.FORMA_UPLOAD + self.object.save() + + prilohy.instance = self.object + prilohy.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') diff --git a/seminar/admin.py b/seminar/admin.py index 3ee14541..9b31627e 100644 --- a/seminar/admin.py +++ b/seminar/admin.py @@ -230,27 +230,6 @@ class SoustredeniAdmin(admin.ModelAdmin): inlines = [SoustredeniUcastniciInline, SoustredeniOrganizatoriInline] -class PrilohaReseniInline(admin.TabularInline): - model = m.PrilohaReseni - extra = 1 -admin.site.register(m.PrilohaReseni) - -class Reseni_ResiteleInline(admin.TabularInline): - model = m.Reseni_Resitele - - -@admin.register(m.Reseni) -class ReseniAdmin(ReverseModelAdmin): - base_model = m.Reseni - inline_type = 'tabular' - # inline_reverse = ['text_cely','resitele'] TODO vrátit zpět a zrychlit dotaz - inline_reverse = ['resitele'] - exclude = ['text_zkraceny', 'text_zkraceny_set'] - inlines = [PrilohaReseniInline] -# FAIL in template -# inlines = [PrilohaReseniInline,Reseni_ResiteleInline] - -admin.site.register(m.Hodnoceni) admin.site.register(m.Pohadka) admin.site.register(m.Obrazek) diff --git a/seminar/forms.py b/seminar/forms.py index 19ed8d9f..6c4ad7a1 100644 --- a/seminar/forms.py +++ b/seminar/forms.py @@ -3,10 +3,8 @@ from dal import autocomplete from django.contrib.auth.forms import PasswordResetForm from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User -from django.forms import formset_factory -from django.forms.models import inlineformset_factory -from .models import Skola, Resitel, Osoba, Problem +from .models import Skola, Resitel, Osoba import seminar.models as m from datetime import date @@ -222,205 +220,8 @@ class PoMaturiteProfileEditForm(ProfileEditForm): label='Rok maturity', required=True) -class VlozReseniForm(forms.Form): - #FIXME jen podproblémy daného problému - problem = forms.ModelChoiceField(label='Problém',queryset=m.Problem.objects.all()) - # to_field_name - #problem = models.ManyToManyField(Problem, verbose_name='problém', help_text='Problém', - # through='Hodnoceni') - - # FIXME pridat vice resitelu - resitel = forms.ModelChoiceField(label="Řešitel", - queryset=Resitel.objects.all(), - widget=autocomplete.ModelSelect2( - url='autocomplete_resitel', - attrs = {'data-placeholder--id': '-1', - 'data-placeholder--text' : '---', - 'data-allow-clear': 'true'}) - ) - - - #resitele = models.ManyToManyField(Resitel, verbose_name='autoři řešení', - # help_text='Seznam autorů řešení', through='Reseni_Resitele') - - cas_doruceni = forms.DateField(widget=DateInput(),label="Čas doručení") - - #cas_doruceni = models.DateTimeField('čas_doručení', default=timezone.now, blank=True) - - forma = forms.ChoiceField(label="Forma řešení",choices = m.Reseni.FORMA_CHOICES) - #forma = models.CharField('forma řešení', max_length=16, choices=FORMA_CHOICES, blank=False, - # default=FORMA_EMAIL) - - poznamka = forms.CharField(label='Neveřejná poznámka', required=False) - #poznamka = models.TextField('neveřejná poznámka', blank=True, - # help_text='Neveřejná poznámka k řešení (plain text)') - - #TODO body do cisla - #TODO prilohy - - ##def __init__(self, *args, **kwargs): - ## super().__init__(*args, **kwargs) - ## #self.fields['favorite_color'] = forms.ChoiceField(choices=[(color.id, color.name) for color in Resitel.objects.all()]) - -class NahrajReseniForm(forms.ModelForm): - class Meta: - model = m.Reseni - fields = ('problem',) - help_texts = {'problem':''} # Nezobrazovat help text ve formuláři - - widgets = {'problem': - autocomplete.ModelSelect2Multiple( - url='autocomplete_problem_odevzdatelny', - attrs = {'data-placeholder--id': '-1', - 'data-placeholder--text' : '---', - 'data-allow-clear': 'true'}, - ) - } - -ReseniSPrilohamiFormSet = inlineformset_factory(m.Reseni,m.PrilohaReseni, - form = NahrajReseniForm, - fields = ('soubor','res_poznamka'), - widgets = {'res_poznamka':forms.TextInput()}, - extra = 1, - can_delete = False, - - ) class NahrajObrazekKTreeNoduForm(forms.ModelForm): class Meta: model = m.Obrazek fields = ('na_web',) - - -class JednoHodnoceniForm(forms.ModelForm): - class Meta: - model = m.Hodnoceni - fields = ('problem', 'body', 'cislo_body') - widgets = { - 'problem': autocomplete.ModelSelect2( - url='autocomplete_problem_odevzdatelny', # FIXME: Dovolit i starší? - ) - } - -OhodnoceniReseniFormSet = formset_factory(JednoHodnoceniForm, - extra = 0, - ) - -class PoznamkaReseniForm(forms.ModelForm): - class Meta: - model = m.Reseni - fields = ('poznamka',) - -# FIXME: Ideálně by mělo být součástí třídy níž, ale neumím to udělat -DATE_FORMAT = '%Y-%m-%d' - -class OdevzdavatkoTabulkaFiltrForm(forms.Form): - """Form pro filtrování přehledové odevzdávátkové tabulky - - Inspirováno https://kam.mff.cuni.cz/mffzoom/""" - - # Věci definované níž se importují i ve views pro odevzdávátko (Inspirováno https://docs.djangoproject.com/en/3.1/ref/models/fields/#field-choices) - - RESITELE_RELEVANTNI = 'relevantni' - RESITELE_NEODMATUROVAVSI = 'neodmaturovavsi' - RESITELE_CHOICES = [ - (RESITELE_RELEVANTNI, 'Relevantní řešitelé'), # I.e. nezobrazovat prázdné řádky tabulky - (RESITELE_NEODMATUROVAVSI, 'Všichni bez maturity'), - # Možná: všechny vč. historických? - ] - - PROBLEMY_MOJE = 'moje' - PROBLEMY_LETOSNI = 'letosni' - PROBLEMY_CHOICES = [ - (PROBLEMY_MOJE, 'Moje problémy'), # Letošní problémy, které mají v sobě nebo v nadproblémech přiřazeného daného orga - (PROBLEMY_LETOSNI, 'Všechny letošní'), - # TODO: *hlavní problémy, možná všechny... - # XXX: Chtělo by to i "aktuálně zadané... - ] - - # TODO: Typy problémů (problémy, úlohy, ostatní, všechny)? Jen některá řešení (obodovaná/neobodovaná, víc řešitelů, ...)? - - - @classmethod - def gen_terminy(cls, rocnik=None): - import datetime - from time import strftime - - from django.db.utils import OperationalError - try: - aktualni_rocnik = m.Nastaveni.get_solo().aktualni_rocnik - aktualni_cislo = m.Nastaveni.get_solo().aktualni_cislo - except OperationalError: - # django.db.utils.OperationalError: no such table: seminar_nastaveni - # Nemáme databázi, takže to selhalo. Pro jistotu vrátíme aspoň dvě možnosti, ať to nepadá dál - logger = logging.getLogger(__name__) - logger.error("Rozbitá databáze (před počátečními migracemi?)") - return [('broken', 'Je to rozbitý'), ('fubar', 'Nefunguje to')] - - # FIXME: Tohle je hnusný monkey patch, mělo by to být nějak zahrnuto výš. - if rocnik is not None: - aktualni_rocnik = rocnik - aktualni_cislo = m.Cislo.objects.filter(rocnik=rocnik).order_by('poradi').last() - - result = [] - - for cislo in m.Cislo.objects.filter( - rocnik=aktualni_rocnik, - poradi__lte=aktualni_cislo.poradi, - ).reverse(): # Standardně se řadí od nejnovějšího čísla - # Předem je mi líto kohokoliv, kdo tyhle řádky bude číst... - if cislo.datum_vydani is not None and cislo.datum_vydani <= datetime.date.today(): - result.append(( - strftime(DATE_FORMAT, cislo.datum_vydani.timetuple()), - f"Vydání {cislo.poradi}. čísla")) - if cislo.datum_preddeadline is not None and cislo.datum_preddeadline <= datetime.date.today(): - result.append(( - strftime(DATE_FORMAT, cislo.datum_preddeadline.timetuple()), - f"Předdeadline {cislo.poradi}. čísla")) - if cislo.datum_deadline_soustredeni is not None and cislo.datum_deadline_soustredeni <= datetime.date.today(): - result.append(( - strftime(DATE_FORMAT, cislo.datum_deadline_soustredeni.timetuple()), - f"Sous. deadline {cislo.poradi}. čísla")) - if cislo.datum_deadline is not None and cislo.datum_deadline <= datetime.date.today(): - result.append(( - strftime(DATE_FORMAT, cislo.datum_deadline.timetuple()), - f"Finální deadline {cislo.poradi}. čísla")) - result.append(( - strftime(DATE_FORMAT, datetime.date.today().timetuple()), f"Dnes")) - - return result - - @classmethod - def gen_initial(cls, rocnik=None): - terminy = cls.gen_terminy(rocnik) - initial = { - 'resitele': cls.RESITELE_RELEVANTNI, - 'problemy': cls.PROBLEMY_MOJE, - # Pokud chceme neaktuální ročník, tak nás nejspíš zajímají všechna řešení… - 'reseni_od': terminy[-2] if rocnik is None else terminy[0], - 'reseni_do': terminy[-1], - 'neobodovane': False, - } - return initial - - def __init__(self, *args, rocnik=None, **kwargs): - if 'initial' not in kwargs: - super().__init__(initial=self.gen_initial(rocnik), *args, **kwargs) - else: - super().__init__(*args, **kwargs) - # choices jako parametr Select widgetu neumí brát callable, jen iterable, takže si pro jednoduchost můžu rovnou uložit výsledek sem... - # A "sem" znamená do libovolné metody, protože jinak se jedná o kód, který django spustí při inicializaci a protože potřebujeme databázi, tak by spadnul při vyrábění testdat... - self.terminy = self.gen_terminy(rocnik) - self.fields['reseni_od'].widget = forms.Select(choices=self.gen_terminy(rocnik)) - # Pokud chceme neaktuální ročník, tak nás nejspíš zajímají všechna řešení… - self.fields['reseni_od'].initial = self.terminy[-2] if rocnik is None else self.terminy[0] - self.fields['reseni_do'].widget = forms.Select(choices=self.gen_terminy(rocnik)) - self.fields['reseni_do'].initial = self.terminy[-1] - - # NOTE: Initial definuji pro jednotlivé fieldy, aby to bylo tady a nebylo potřeba to řešit ve views... - resitele = forms.ChoiceField(choices=RESITELE_CHOICES) - problemy = forms.ChoiceField(choices=PROBLEMY_CHOICES) - - reseni_od = forms.DateField(input_formats=[DATE_FORMAT]) - reseni_do = forms.DateField(input_formats=[DATE_FORMAT]) - neobodovane = forms.BooleanField(required=False) diff --git a/seminar/models/__init__.py b/seminar/models/__init__.py new file mode 100644 index 00000000..2c869390 --- /dev/null +++ b/seminar/models/__init__.py @@ -0,0 +1,2 @@ +from .models_all import * +from .odevzdavatko import * diff --git a/seminar/models.py b/seminar/models/models_all.py similarity index 89% rename from seminar/models.py rename to seminar/models/models_all.py index d4c3ad81..bf749303 100644 --- a/seminar/models.py +++ b/seminar/models/models_all.py @@ -9,7 +9,6 @@ import logging from django.contrib.sites.shortcuts import get_current_site from django.db import models from django.contrib import auth -from django.db.models import Sum from django.utils import timezone from django.conf import settings from django.utils.encoding import force_text @@ -316,6 +315,7 @@ class Resitel(SeminarModelBase): def vsechny_body(self): "Spočítá body odjakživa." vsechna_reseni = self.reseni_set.all() + from .odevzdavatko import Hodnoceni vsechna_hodnoceni = Hodnoceni.objects.filter( reseni__in=vsechna_reseni) return sum(h.body for h in list(vsechna_hodnoceni) if h.body is not None) @@ -362,6 +362,7 @@ class Resitel(SeminarModelBase): # - body z 25. ročníku a dříve byly shledány dvakrát hodnotnějšími # - proto se započítávají dvojnásobně a byly posunuté hranice titulů # - staré tituly se ale nemají odebrat, pokud řešitel v t.č. minulém (26.) ročníku měl titul, má ho mít pořád. + from .odevzdavatko import Hodnoceni hodnoceni_do_25_rocniku = Hodnoceni.objects.filter(cislo_body__rocnik__rocnik__lte=25,reseni__in=self.reseni_set.all()) novejsi_hodnoceni = Hodnoceni.objects.filter(reseni__in=self.reseni_set.all()).difference(hodnoceni_do_25_rocniku) @@ -399,6 +400,7 @@ class Resitel(SeminarModelBase): else: return Titul.akad + from .odevzdavatko import Hodnoceni hodnoceni_do_26_rocniku = Hodnoceni.objects.filter(cislo_body__rocnik__rocnik__lte=26,reseni__in=self.reseni_set.all()) novejsi_body = body_z_hodnoceni( Hodnoceni.objects.filter(reseni__in=self.reseni_set.all()) @@ -1084,101 +1086,6 @@ class Uloha(Problem): zadani_node = self.ulohazadaninode return treelib.get_upper_node_of_type(zadani_node, CisloNode) -@reversion.register(ignore_duplicates=True) -class Reseni(SeminarModelBase): - - class Meta: - db_table = 'seminar_reseni' - verbose_name = 'Řešení' - verbose_name_plural = 'Řešení' - #ordering = ['-problem', 'resitele'] # FIXME: Takhle to chceme, ale nefunguje to. - ordering = ['-cas_doruceni'] - - # Interní ID - id = models.AutoField(primary_key = True) - - # Ke každé dvojici řešní a problém existuje nanejvýš jedno hodnocení, doplnění vazby. - problem = models.ManyToManyField(Problem, verbose_name='problém', help_text='Problém', - through='Hodnoceni') - - resitele = models.ManyToManyField(Resitel, verbose_name='autoři řešení', - help_text='Seznam autorů řešení', through='Reseni_Resitele') - - - cas_doruceni = models.DateTimeField('čas_doručení', default=timezone.now, blank=True) - - FORMA_PAPIR = 'papir' - FORMA_EMAIL = 'email' - FORMA_UPLOAD = 'upload' - FORMA_CHOICES = [ - (FORMA_PAPIR, 'Papírové řešení'), - (FORMA_EMAIL, 'Emailem'), - (FORMA_UPLOAD, 'Upload přes web'), - ] - forma = models.CharField('forma řešení', max_length=16, choices=FORMA_CHOICES, blank=False, - default=FORMA_EMAIL) - - text_cely = models.OneToOneField('ReseniNode', verbose_name='Plná verze textu řešení', - blank=True, null=True, related_name="reseni_cely_set", - on_delete=models.PROTECT) - - poznamka = models.TextField('neveřejná poznámka', blank=True, - help_text='Neveřejná poznámka k řešení (plain text)') - - zverejneno = models.BooleanField('řešení zveřejněno', default=False, - help_text='Udává, zda je řešení zveřejněno') - - def verejne_url(self): - return str(reverse_lazy('odevzdavatko_detail_reseni', args=[self.id])) - - def absolute_url(self): - return "https://" + str(get_current_site(None)) + self.verejne_url() - - # má OneToOneField s: - # Konfera - - # má ForeignKey s: - # Hodnoceni - - def sum_body(self): - return self.hodnoceni_set.all().aggregate(Sum('body'))["body__sum"] - - def __str__(self): - return "{}({}): {}({})".format(self.resitele.first(),len(self.resitele.all()), self.problem.first() ,len(self.problem.all())) - # NOTE: Potenciální DB HOG (bez select_related) - -## Pravdepodobne uz nebude potreba: -# def save(self, *args, **kwargs): -# if ((self.cislo_body is None) and (self.problem.cislo_reseni) and -# (self.problem.typ == Problem.TYP_ULOHA)): -# self.cislo_body = self.problem.cislo_reseni -# super(Reseni, self).save(*args, **kwargs) - -class Hodnoceni(SeminarModelBase): - class Meta: - db_table = 'seminar_hodnoceni' - verbose_name = 'Hodnocení' - verbose_name_plural = 'Hodnocení' - - # Interní ID - id = models.AutoField(primary_key = True) - - - body = models.DecimalField(max_digits=8, decimal_places=1, verbose_name='body', - blank=True, null=True) - - cislo_body = models.ForeignKey(Cislo, verbose_name='číslo pro body', - related_name='hodnoceni', blank=True, null=True, on_delete=models.PROTECT) - - reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE) - - problem = models.ForeignKey(Problem, verbose_name='problém', - related_name='hodnoceni', on_delete=models.PROTECT) - - def __str__(self): - return "{}, {}, {}".format(self.problem, self.reseni, self.body) - - def aux_generate_filename(self, filename): """Pomocná funkce generující ošetřený název souboru v adresáři s datem""" @@ -1204,45 +1111,6 @@ def generate_filename_konfera(self, filename): ) ## -def generate_filename(self, filename): - return os.path.join( - settings.SEMINAR_RESENI_DIR, - aux_generate_filename(self, filename) - ) - - -@reversion.register(ignore_duplicates=True) -class PrilohaReseni(SeminarModelBase): - - class Meta: - db_table = 'seminar_priloha_reseni' - verbose_name = 'Příloha řešení' - verbose_name_plural = 'Přílohy řešení' - ordering = ['reseni', 'vytvoreno'] - - # Interní ID - id = models.AutoField(primary_key = True) - - reseni = models.ForeignKey(Reseni, verbose_name='řešení', related_name='prilohy', - on_delete=models.CASCADE) - - vytvoreno = models.DateTimeField('vytvořeno', default=timezone.now, blank=True, editable=False) - - soubor = models.FileField('soubor', upload_to = generate_filename) - - poznamka = models.TextField('neveřejná poznámka', blank=True, - help_text='Neveřejná poznámka k příloze řešení (plain text), např. o původu') - - res_poznamka = models.TextField('poznámka řešitele', blank=True, - help_text='Poznámka k příloze řešení, např. co daný soubor obsahuje') - - def __str__(self): - return str(self.soubor) - - def split(self): - "Vrátí cestu rozsekanou po složkách. To se hodí v templatech" - # Věřím, že tohle funguje, případně použít os.path nebo pathlib. - return self.soubor.url.split('/') class Pohadka(SeminarModelBase): @@ -1385,29 +1253,6 @@ class Konfera(Problem): def cislo_node(self): return None -# Vazebna tabulka. Mozna se generuje automaticky. -@reversion.register(ignore_duplicates=True) -class Reseni_Resitele(models.Model): - - class Meta: - db_table = 'seminar_reseni_resitele' - verbose_name = 'Řešení řešitelů' - verbose_name_plural = 'Řešení řešitelů' - ordering = ['reseni', 'resitele'] - - # Interní ID - id = models.AutoField(primary_key = True) - - resitele = models.ForeignKey(Resitel, verbose_name='řešitel', on_delete=models.PROTECT) - - reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE) - - # podil - jakou merou se ktery resitel podilel na danem reseni - # - pouziti v budoucnu, pokud by resitele nemeli dostat vsichni stejne bodu za spolecne reseni - - def __str__(self): - return '{} od {}'.format(self.reseni, self.resitel) - # NOTE: Poteciální DB HOG bez select_related @reversion.register(ignore_duplicates=True) class Konfery_Ucastnici(models.Model): @@ -1705,21 +1550,6 @@ class CastNode(TreeNode): def getOdkazStr(self): return str(self.nadpis) -class ReseniNode(TreeNode): - class Meta: - db_table = 'seminar_nodes_otistene_reseni' - verbose_name = 'Otištěné řešení (Node)' - verbose_name_plural = 'Otištěná řešení (Node)' - reseni = models.ForeignKey(Reseni, - on_delete=models.PROTECT, - verbose_name = 'reseni') - - def aktualizuj_nazev(self): - self.nazev = "ReseniNode: "+str(self.reseni) - - def getOdkazStr(self): - return str(self.reseni) - @reversion.register(ignore_duplicates=True) class Nastaveni(SingletonModel): diff --git a/seminar/models/odevzdavatko.py b/seminar/models/odevzdavatko.py new file mode 100644 index 00000000..ce6f88a7 --- /dev/null +++ b/seminar/models/odevzdavatko.py @@ -0,0 +1,188 @@ +import os + +import reversion + +from django.contrib.sites.shortcuts import get_current_site +from django.db import models +from django.db.models import Sum +from django.urls import reverse_lazy +from django.utils import timezone +from django.conf import settings + +from seminar.models import models_all as am + + +@reversion.register(ignore_duplicates=True) +class Reseni(am.SeminarModelBase): + + class Meta: + db_table = 'seminar_reseni' + verbose_name = 'Řešení' + verbose_name_plural = 'Řešení' + #ordering = ['-problem', 'resitele'] # FIXME: Takhle to chceme, ale nefunguje to. + ordering = ['-cas_doruceni'] + + # Interní ID + id = models.AutoField(primary_key = True) + + # Ke každé dvojici řešní a problém existuje nanejvýš jedno hodnocení, doplnění vazby. + problem = models.ManyToManyField(am.Problem, verbose_name='problém', help_text='Problém', + through='Hodnoceni') + + resitele = models.ManyToManyField(am.Resitel, verbose_name='autoři řešení', + help_text='Seznam autorů řešení', through='Reseni_Resitele') + + + cas_doruceni = models.DateTimeField('čas_doručení', default=timezone.now, blank=True) + + FORMA_PAPIR = 'papir' + FORMA_EMAIL = 'email' + FORMA_UPLOAD = 'upload' + FORMA_CHOICES = [ + (FORMA_PAPIR, 'Papírové řešení'), + (FORMA_EMAIL, 'Emailem'), + (FORMA_UPLOAD, 'Upload přes web'), + ] + forma = models.CharField('forma řešení', max_length=16, choices=FORMA_CHOICES, blank=False, + default=FORMA_EMAIL) + + text_cely = models.OneToOneField('ReseniNode', verbose_name='Plná verze textu řešení', + blank=True, null=True, related_name="reseni_cely_set", + on_delete=models.PROTECT) + + poznamka = models.TextField('neveřejná poznámka', blank=True, + help_text='Neveřejná poznámka k řešení (plain text)') + + zverejneno = models.BooleanField('řešení zveřejněno', default=False, + help_text='Udává, zda je řešení zveřejněno') + + def verejne_url(self): + return str(reverse_lazy('odevzdavatko_detail_reseni', args=[self.id])) + + def absolute_url(self): + return "https://" + str(get_current_site(None)) + self.verejne_url() + + # má OneToOneField s: + # Konfera + + # má ForeignKey s: + # Hodnoceni + + def sum_body(self): + return self.hodnoceni_set.all().aggregate(Sum('body'))["body__sum"] + + def __str__(self): + return "{}({}): {}({})".format(self.resitele.first(),len(self.resitele.all()), self.problem.first() ,len(self.problem.all())) + # NOTE: Potenciální DB HOG (bez select_related) + +## Pravdepodobne uz nebude potreba: +# def save(self, *args, **kwargs): +# if ((self.cislo_body is None) and (self.problem.cislo_reseni) and +# (self.problem.typ == Problem.TYP_ULOHA)): +# self.cislo_body = self.problem.cislo_reseni +# super(Reseni, self).save(*args, **kwargs) + +class Hodnoceni(am.SeminarModelBase): + class Meta: + db_table = 'seminar_hodnoceni' + verbose_name = 'Hodnocení' + verbose_name_plural = 'Hodnocení' + + # Interní ID + id = models.AutoField(primary_key = True) + + + body = models.DecimalField(max_digits=8, decimal_places=1, verbose_name='body', + blank=True, null=True) + + cislo_body = models.ForeignKey(am.Cislo, verbose_name='číslo pro body', + related_name='hodnoceni', blank=True, null=True, on_delete=models.PROTECT) + + reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE) + + problem = models.ForeignKey(am.Problem, verbose_name='problém', + related_name='hodnoceni', on_delete=models.PROTECT) + + def __str__(self): + return "{}, {}, {}".format(self.problem, self.reseni, self.body) + +def generate_filename(self, filename): + return os.path.join( + settings.SEMINAR_RESENI_DIR, + am.aux_generate_filename(self, filename) + ) + + +@reversion.register(ignore_duplicates=True) +class PrilohaReseni(am.SeminarModelBase): + + class Meta: + db_table = 'seminar_priloha_reseni' + verbose_name = 'Příloha řešení' + verbose_name_plural = 'Přílohy řešení' + ordering = ['reseni', 'vytvoreno'] + + # Interní ID + id = models.AutoField(primary_key = True) + + reseni = models.ForeignKey(Reseni, verbose_name='řešení', related_name='prilohy', + on_delete=models.CASCADE) + + vytvoreno = models.DateTimeField('vytvořeno', default=timezone.now, blank=True, editable=False) + + soubor = models.FileField('soubor', upload_to = generate_filename) + + poznamka = models.TextField('neveřejná poznámka', blank=True, + help_text='Neveřejná poznámka k příloze řešení (plain text), např. o původu') + + res_poznamka = models.TextField('poznámka řešitele', blank=True, + help_text='Poznámka k příloze řešení, např. co daný soubor obsahuje') + + def __str__(self): + return str(self.soubor) + + def split(self): + "Vrátí cestu rozsekanou po složkách. To se hodí v templatech" + # Věřím, že tohle funguje, případně použít os.path nebo pathlib. + return self.soubor.url.split('/') + + +# Vazebna tabulka. Mozna se generuje automaticky. +@reversion.register(ignore_duplicates=True) +class Reseni_Resitele(models.Model): + + class Meta: + db_table = 'seminar_reseni_resitele' + verbose_name = 'Řešení řešitelů' + verbose_name_plural = 'Řešení řešitelů' + ordering = ['reseni', 'resitele'] + + # Interní ID + id = models.AutoField(primary_key = True) + + resitele = models.ForeignKey(am.Resitel, verbose_name='řešitel', on_delete=models.PROTECT) + + reseni = models.ForeignKey(Reseni, verbose_name='řešení', on_delete=models.CASCADE) + + # podil - jakou merou se ktery resitel podilel na danem reseni + # - pouziti v budoucnu, pokud by resitele nemeli dostat vsichni stejne bodu za spolecne reseni + + def __str__(self): + return '{} od {}'.format(self.reseni, self.resitel) + # NOTE: Poteciální DB HOG bez select_related + +class ReseniNode(am.TreeNode): + class Meta: + db_table = 'seminar_nodes_otistene_reseni' + verbose_name = 'Otištěné řešení (Node)' + verbose_name_plural = 'Otištěná řešení (Node)' + reseni = models.ForeignKey(Reseni, + on_delete=models.PROTECT, + verbose_name = 'reseni') + + def aktualizuj_nazev(self): + self.nazev = "ReseniNode: "+str(self.reseni) + + def getOdkazStr(self): + return str(self.reseni) + diff --git a/seminar/urls.py b/seminar/urls.py index 8a6aacf4..4a94e27b 100644 --- a/seminar/urls.py +++ b/seminar/urls.py @@ -1,7 +1,7 @@ from django.urls import path, include, re_path from django.contrib.auth.decorators import login_required from . import views -from .utils import org_required, resitel_required, viewMethodSwitch, resitel_or_org_required +from .utils import org_required urlpatterns = [ # path('aktualni/temata/', views.TemataRozcestnikView), @@ -116,8 +116,6 @@ urlpatterns = [ path('prihlaska/',views.prihlaskaView, name='seminar_prihlaska'), - path('resitel/odevzdana_reseni/', resitel_or_org_required(views.PrehledOdevzdanychReseni.as_view()), name='seminar_resitel_odevzdana_reseni'), - path( 'resitel/osobni-udaje/', login_required(views.resitelEditView), @@ -127,19 +125,9 @@ urlpatterns = [ # Obecný view na profil -- orgům dá rozcestník, řešitelům jejich stránku path('profil/', views.profilView, name='profil'), - path('org/add_solution', org_required(views.AddSolutionView.as_view()), name='seminar_vloz_reseni'), - path('resitel/nahraj_reseni', resitel_required(views.NahrajReseniView.as_view()), name='seminar_nahraj_reseni'), - re_path(r'^temp/vue/.*$',views.VueTestView.as_view(),name='vue_test_view'), path('temp/image_upload/', views.NahrajObrazekKTreeNoduView.as_view()), path('', views.TitulniStranaView.as_view(), name='titulni_strana'), path('jak-resit/', views.JakResitView.as_view(), name='jak_resit'), - - path('org/reseni/', org_required(views.TabulkaOdevzdanychReseniView.as_view()), name='odevzdavatko_tabulka'), - path('org/reseni/rocnik//', org_required(views.TabulkaOdevzdanychReseniView.as_view()), name='odevzdavatko_tabulka'), - path('org/reseni///', org_required(views.ReseniProblemuView.as_view()), name='odevzdavatko_reseni_resitele_k_problemu'), - path('org/reseni/', org_required(viewMethodSwitch(get=views.DetailReseniView.as_view(), post=views.hodnoceniReseniView)), name='odevzdavatko_detail_reseni'), - path('org/reseni/all', org_required(views.SeznamReseniView.as_view())), - path('org/reseni/akt', org_required(views.SeznamAktualnichReseniView.as_view())), ] diff --git a/seminar/views/__init__.py b/seminar/views/__init__.py index 785102e5..22c60734 100644 --- a/seminar/views/__init__.py +++ b/seminar/views/__init__.py @@ -1,6 +1,5 @@ from .views_all import * from .views_rest import * -from .odevzdavatko import * # Dočsasné views from .docasne import * diff --git a/seminar/views/views_all.py b/seminar/views/views_all.py index 56e2aaae..d5e3e54c 100644 --- a/seminar/views/views_all.py +++ b/seminar/views/views_all.py @@ -1,26 +1,22 @@ from django.shortcuts import get_object_or_404, render, redirect -from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden, JsonResponse -from django.urls import reverse,reverse_lazy -from django.core.exceptions import PermissionDenied, ObjectDoesNotExist -from django.core.mail import send_mail +from django.http import HttpResponse, JsonResponse +from django.urls import reverse +from django.core.exceptions import ObjectDoesNotExist from django.views import generic from django.utils.translation import ugettext as _ -from django.http import Http404,HttpResponseBadRequest,HttpResponseRedirect +from django.http import Http404 from django.db.models import Q, Sum, Count -from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.debug import sensitive_post_parameters -from django.views.generic.edit import FormView, CreateView +from django.views.generic.edit import CreateView from django.views.generic.base import TemplateView, RedirectView from django.contrib.auth.models import User, Permission, Group from django.contrib.auth.mixins import LoginRequiredMixin from django.db import transaction -from django.core import serializers from django.core.exceptions import PermissionDenied -from django.forms.models import model_to_dict import seminar.models as s import seminar.models as m -from seminar.models import Problem, Cislo, Reseni, Nastaveni, Rocnik, Soustredeni, Organizator, Resitel, Novinky, Soustredeni_Ucastnici, Pohadka, Tema, Clanek, Osoba, Skola # Tohle je stare a chceme se toho zbavit. Pouzivejte s.ToCoChci +from seminar.models import Problem, Cislo, Reseni, Nastaveni, Rocnik, Soustredeni, Organizator, Resitel, Novinky, Soustredeni_Ucastnici, Tema, Clanek, Osoba # Tohle je stare a chceme se toho zbavit. Pouzivejte s.ToCoChci #from .models import VysledkyZaCislo, VysledkyKCisluZaRocnik, VysledkyKCisluOdjakziva from seminar import utils, treelib from seminar.forms import PrihlaskaForm, ProfileEditForm, PoMaturiteProfileEditForm @@ -29,7 +25,7 @@ import seminar.templatetags.treenodes as tnltt import seminar.views.views_rest as vr from seminar.views.vysledkovka import vysledkovka_rocniku, vysledkovka_cisla, body_resitelu -from datetime import timedelta, date, datetime, MAXYEAR +from datetime import date, datetime from django.utils import timezone from itertools import groupby from collections import OrderedDict @@ -40,14 +36,11 @@ import os import os.path as op from django.conf import settings import unicodedata -import json -import traceback -import sys import csv import logging import time -from seminar.utils import aktivniResitele, resi_v_rocniku, problemy_rocniku, cisla_rocniku, hlavni_problemy_f +from seminar.utils import aktivniResitele, problemy_rocniku, cisla_rocniku, hlavni_problemy_f from various.autentizace.views import LoginView from various.autentizace.utils import posli_reset_hesla @@ -1040,97 +1033,6 @@ class ResitelView(LoginRequiredMixin,generic.DetailView): # - přidat do forms # - includovat do html -class AddSolutionView(LoginRequiredMixin, FormView): - template_name = 'seminar/org/vloz_reseni.html' - form_class = f.VlozReseniForm - - 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() - # Chtěl jsem, aby bylo vidět, že se to uložilo, tak přesměrovávám na profil. - return redirect(reverse('profil')) - - -class NahrajReseniView(LoginRequiredMixin, CreateView): - model = s.Reseni - template_name = 'seminar/profil/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(Resitel.objects.get(osoba__user = self.request.user)) - self.object.cas_doruceni = timezone.now() - self.object.forma = s.Reseni.FORMA_UPLOAD - self.object.save() - - prilohy.instance = self.object - prilohy.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 })") - - 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') - - def prihlaska_log_gdpr_safe(logger, gdpr_logger, msg, form_data): msg = "{}, form_hash:{}".format(msg,hash(frozenset(form_data.items))) logger.warn(msg)