# -*- coding: utf-8 -*- import datetime import os import subprocess import pathlib import tempfile import logging from django.contrib.sites.shortcuts import get_current_site from django.db import models from django.db.models import Q from django.template.loader import render_to_string from django.utils import timezone from django.conf import settings from django.urls import reverse from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.files.storage import FileSystemStorage from django.utils.text import get_valid_filename from django.utils.functional import cached_property from solo.models import SingletonModel from taggit.managers import TaggableManager from reversion import revisions as reversion from seminar.utils import roman from treenode import treelib from unidecode import unidecode # Používám pro získání ID odkazu (ještě je to někde po někom zakomentované) from polymorphic.models import PolymorphicModel from django.core.mail import EmailMessage from seminar.utils import aktivniResitele from . import personalni as pm from .base import SeminarModelBase logger = logging.getLogger(__name__) class OverwriteStorage(FileSystemStorage): """ Varianta FileSystemStorage, která v případě, že soubor cílového jména již existuje, ho smaže a místo něj uloží soubor nový""" def get_available_name(self,name, max_length=None): if self.exists(name): os.remove(os.path.join(self.location,name)) return super().get_available_name(name,max_length) @reversion.register(ignore_duplicates=True) class Rocnik(SeminarModelBase): class Meta: db_table = 'seminar_rocniky' verbose_name = 'Ročník' verbose_name_plural = 'Ročníky' ordering = ['-rocnik'] # Interní ID id = models.AutoField(primary_key = True) prvni_rok = models.IntegerField('první rok', db_index=True, unique=True) rocnik = models.IntegerField('číslo ročníku', db_index=True, unique=True) exportovat = models.BooleanField('export do AESOPa', db_column='exportovat', default=False, help_text='Exportuje se jen podle tohoto flagu (ne veřejnosti),' ' a to jen čísla s veřejnou výsledkovkou') # má OneToOneField s: # RocnikNode def __str__(self): return '{} ({}/{})'.format(self.rocnik, self.prvni_rok, self.prvni_rok+1) # Ročník v římských číslech def roman(self): return roman(int(self.rocnik)) def verejne(self): return len(self.verejna_cisla()) > 0 verejne.boolean = True verejne.short_description = 'Veřejný (jen dle čísel)' def neverejna_cisla(self): vc = [c for c in self.cisla.all() if not c.verejne()] vc.sort(key=lambda c: c.poradi) return vc def verejna_cisla(self): vc = [c for c in self.cisla.all() if c.verejne()] vc.sort(key=lambda c: c.poradi) return vc def posledni_verejne_cislo(self): vc = self.verejna_cisla() return vc[-1] if vc else None def verejne_vysledkovky_cisla(self): vc = list(self.cisla.filter(deadline_v_cisle__verejna_vysledkovka=True).distinct()) vc.sort(key=lambda c: c.poradi) return vc def posledni_zverejnena_vysledkovka_cislo(self): vc = self.verejne_vysledkovky_cisla() return vc[-1] if vc else None def druhy_rok(self): return self.prvni_rok + 1 def verejne_url(self): return reverse('seminar_rocnik', kwargs={'rocnik': self.rocnik}) @classmethod def cached_rocnik(cls, r_id): name = 'rocnik_%s' % (r_id, ) c = cache.get(name) if c is None: c = cls.objects.get(id=r_id) cache.set(name, c, 300) return c def save(self, *args, **kwargs): super().save(*args, **kwargs) # *Node.save() aktualizuje název *Nodu. try: self.rocniknode.save() except ObjectDoesNotExist: # Neexistující *Node nemá smysl aktualizovat. pass def cislo_pdf_filename(self, filename): rocnik = str(self.rocnik.rocnik) return pathlib.Path('cislo', 'pdf', rocnik, '{}-{}.pdf'.format(rocnik, self.poradi)) def cislo_png_filename(self, filename): rocnik = str(self.rocnik.rocnik) return pathlib.Path('cislo', 'png', rocnik, '{}-{}.png'.format(rocnik, self.poradi)) @reversion.register(ignore_duplicates=True) class Cislo(SeminarModelBase): class Meta: db_table = 'seminar_cisla' verbose_name = 'Číslo' verbose_name_plural = 'Čísla' ordering = ['-rocnik__rocnik', '-poradi'] # Interní ID id = models.AutoField(primary_key = True) rocnik = models.ForeignKey(Rocnik, verbose_name='ročník', related_name='cisla', db_index=True,on_delete=models.PROTECT) poradi = models.CharField('název čísla', max_length=32, db_index=True, help_text='Většinou jen "1", vyjímečně "7-8", lexikograficky určuje pořadí v ročníku!') datum_vydani = models.DateField('datum vydání', blank=True, null=True, help_text='Datum vydání finální verze') verejne_db = models.BooleanField('číslo zveřejněno', db_column='verejne', default=False) poznamka = models.TextField('neveřejná poznámka', blank=True, help_text='Neveřejná poznámka k číslu (plain text)') pdf = models.FileField('pdf', upload_to=cislo_pdf_filename, null=True, blank=True, help_text='PDF čísla, které si mohou řešitelé stáhnout', storage=OverwriteStorage()) titulka_nahled = models.ImageField('Obrázek titulní strany', upload_to=cislo_png_filename, null=True, blank=True, help_text='Obrázek titulní strany, generuje se automaticky') # má OneToOneField s: # CisloNode def kod(self): return '%s.%s' % (self.rocnik.rocnik, self.poradi) kod.short_description = 'Kód čísla' def __str__(self): # Potenciální DB HOG, pokud by se ročník necachoval r = Rocnik.cached_rocnik(self.rocnik_id) return '{}.{}'.format(r.rocnik, self.poradi) def verejne(self): return self.verejne_db verejne.boolean = True def verejne_url(self): return reverse('seminar_cislo', kwargs={'rocnik': self.rocnik.rocnik, 'cislo': self.poradi}) def absolute_url(self): return "https://" + str(get_current_site(None)) + self.verejne_url() def nasledujici(self): "Vrací None, pokud je toto poslední" return self.relativni_v_rocniku(1) def predchozi(self): "Vrací None, pokud je toto první" return self.relativni_v_rocniku(-1) def relativni_v_rocniku(self, rel_index): "Číslo o `index` dále v ročníku. None pokud neexistuje." cs = self.rocnik.cisla.order_by('poradi').all() i = list(cs).index(self) + rel_index if (i < 0) or (i >= len(cs)): return None return cs[i] def vygeneruj_nahled(self): VYSKA = 594 sirka = int(VYSKA*210/297) if not self.pdf: return # Pokud obrázek neexistuje nebo není aktuální, vytvoř jej if not self.titulka_nahled or os.path.getmtime(self.titulka_nahled.path) < os.path.getmtime(self.pdf.path): png_filename = pathlib.Path(tempfile.mkdtemp(), 'nahled.png') subprocess.run([ "gs", "-sstdout=%stderr", "-dSAFER", "-dNOPAUSE", "-dBATCH", "-dNOPROMPT", "-sDEVICE=png16m", "-r300x300", "-dFirstPage=1d", "-dLastPage=1d", "-sOutputFile=" + str(png_filename), "-f%s" % self.pdf.path ], check=True, capture_output=True ) with open(png_filename,'rb') as f: self.titulka_nahled.save('',f,True) png_filename.unlink() png_filename.parent.rmdir() @classmethod def get(cls, rocnik, cislo): try: r = Rocnik.objects.get(rocnik=rocnik) c = r.cisla.get(poradi=cislo) except ObjectDoesNotExist: return None return c def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__original_verejne = self.verejne_db def posli_cislo_mailem(self): # parametry e-mailu odkaz = self.absolute_url() poslat_z_mailu = 'zadani@mam.mff.cuni.cz' predmet = 'Vyšlo číslo {}'.format(self.kod()) text_mailu = 'Ahoj,\n' \ 'na adrese {} najdete nejnovější číslo.\n' \ 'Vaše M&M\n'.format(odkaz) # Prijemci e-mailu emaily = map(lambda r: r.osoba.email, filter(lambda r: r.zasilat_cislo_emailem, aktivniResitele(self))) if not settings.POSLI_MAILOVOU_NOTIFIKACI: print("Poslal bych upozornění na tyto adresy: ", " ".join(emaily)) return email = EmailMessage( subject=predmet, body=text_mailu, from_email=poslat_z_mailu, bcc=list(emaily) #bcc = příjemci skryté kopie ) email.send() def save(self, *args, **kwargs): super().save(*args, **kwargs) self.vygeneruj_nahled() # Při zveřejnění pošle mail if self.verejne_db and not self.__original_verejne: self.posli_cislo_mailem() # *Node.save() aktualizuje název *Nodu. try: self.cislonode.save() except ObjectDoesNotExist: # Neexistující *Node nemá smysl aktualizovat, ale je potřeba ho naopak vyrobit logger.warning(f'Číslo {self} nemělo ČísloNode, vyrábím…') from seminar.models.treenode import CisloNode CisloNode.objects.create(cislo=self) def zlomovy_deadline_pro_papirove_cislo(self): prvni_deadline = Deadline.objects.filter(Q(typ=Deadline.TYP_PRVNI) | Q(typ=Deadline.TYP_PRVNI_A_SOUS), cislo=self).first() if prvni_deadline is None: posledni_deadline = self.posledni_deadline if posledni_deadline is None: # TODO promyslet, co se má stát tady return Deadline.objects.filter(Q(cislo__poradi__lt=self.poradi, cislo__rocnik=self.rocnik) | Q(cislo__rocnik__rocnik__lt=self.rocnik.rocnik)).order_by("deadline").last() return posledni_deadline return prvni_deadline @property def posledni_deadline(self): return self.deadline_v_cisle.all().order_by("deadline").last() class Deadline(SeminarModelBase): class Meta: db_table = 'seminar_deadliny' verbose_name = 'Deadline' verbose_name_plural = 'Deadliny' ordering = ['deadline'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__original_verejna_vysledkovka = self.verejna_vysledkovka id = models.AutoField(primary_key=True) # V ročníku < 26 nastaveno na datetime.datetime.combine(datetime.date(1994 + cislo.rocnik.rocnik, 6, int(cislo.poradi[0])), datetime.time.min) deadline = models.DateTimeField(blank=False, default=timezone.make_aware(datetime.datetime.combine(timezone.now(), datetime.time.max))) cislo = models.ForeignKey(Cislo, verbose_name='deadline v čísle', related_name='deadline_v_cisle', blank=False, on_delete=models.CASCADE) TYP_CISLA = 'cisla' TYP_PRVNI_A_SOUS = 'prvniasous' TYP_PRVNI = 'prvni' TYP_SOUS = 'sous' TYP_CHOICES = [ (TYP_CISLA, 'Deadline celého čísla'), (TYP_PRVNI, 'První deadline'), (TYP_PRVNI_A_SOUS, 'Sousový a první deadline'), (TYP_SOUS, 'Sousový deadline'), ] CHOICES_MAP = dict(TYP_CHOICES) typ = models.CharField('typ deadlinu', max_length=32, choices=TYP_CHOICES, blank=False) verejna_vysledkovka = models.BooleanField('veřejná výsledkovka', db_column='verejna_vysledkovka', default=False) def __str__(self): return self.CHOICES_MAP[self.typ] + " " + str(self.cislo) def save(self, *args, **kwargs): super().save(*args, **kwargs) if self.verejna_vysledkovka and not self.__original_verejna_vysledkovka: self.vygeneruj_vysledkovku() if not self.verejna_vysledkovka and hasattr(self, "vysledkovka_v_deadlinu"): self.vysledkovka_v_deadlinu.delete() def vygeneruj_vysledkovku(self): from vysledkovky.utils import VysledkovkaCisla if hasattr(self, "vysledkovka_v_deadlinu"): self.vysledkovka_v_deadlinu.delete() vysledkovka = VysledkovkaCisla(self.cislo, jen_verejne=True, do_deadlinu=self) if len(vysledkovka.radky_vysledkovky) != 0: ZmrazenaVysledkovka.objects.create( deadline=self, html=render_to_string( "vysledkovky/vysledkovka_cisla.html", context={"vysledkovka": vysledkovka, "oznaceni_vysledkovky": self.id} ) ) class ZmrazenaVysledkovka(SeminarModelBase): class Meta: db_table = 'seminar_vysledkovky' verbose_name = 'Zmražená výsledkovka' verbose_name_plural = 'Zmražené výsledkovky' deadline = models.OneToOneField( Deadline, on_delete=models.CASCADE, primary_key=True, related_name="vysledkovka_v_deadlinu" ) html = models.TextField(null=False, blank=False) @reversion.register(ignore_duplicates=True) # Pozor na následující řádek. *Nekrmit, asi kouše!* class Problem(SeminarModelBase,PolymorphicModel): class Meta: # Není abstraktní, protože se na něj jinak nedají dělat ForeignKeys. # TODO: Udělat to polymorfní (pomocí django-polymorphic), abychom dostali # po těch vazbách přímo tu úlohu/témátko vč. fieldů, které nejsou součástí # modelu Problem? #abstract = True db_table = 'seminar_problemy' verbose_name = 'Problém' verbose_name_plural = 'Problémy' ordering = ['nazev'] # Interní ID id = models.AutoField(primary_key = True) # Název nazev = models.CharField('název', max_length=256) # Zveřejnitelný na stránky # Problém má podproblémy nadproblem = models.ForeignKey('self', verbose_name='nadřazený problém', related_name='podproblem', null=True, blank=True, on_delete=models.SET_NULL) STAV_NAVRH = 'navrh' STAV_ZADANY = 'zadany' STAV_VYRESENY = 'vyreseny' STAV_SMAZANY = 'smazany' STAV_CHOICES = [ (STAV_NAVRH, 'Návrh'), (STAV_ZADANY, 'Zadaný'), (STAV_VYRESENY, 'Vyřešený'), (STAV_SMAZANY, 'Smazaný'), ] stav = models.CharField('stav problému', max_length=32, choices=STAV_CHOICES, blank=False, default=STAV_NAVRH) # Téma je taky Problém, takže má stavy, "zadané" témátko je aktuálně otevřené a dá se k němu něco poslat (řešení nebo článek) zamereni = TaggableManager(verbose_name='zaměření', help_text='Zaměření M/F/I/O problému, příp. další tagy', blank=True) poznamka = models.TextField('org poznámky (HTML)', blank=True, help_text='Neveřejný návrh úlohy, návrh řešení, text zadání, poznámky ...') autor = models.ForeignKey(pm.Organizator, verbose_name='autor problému', related_name='autor_problemu_%(class)s', null=True, blank=True, on_delete=models.SET_NULL) garant = models.ForeignKey(pm.Organizator, verbose_name='garant zadaného problému', related_name='garant_problemu_%(class)s', null=True, blank=True, on_delete=models.SET_NULL) opravovatele = models.ManyToManyField(pm.Organizator, verbose_name='opravovatelé', blank=True, related_name='opravovatele_%(class)s') kod = models.CharField('lokální kód', max_length=32, blank=True, default='', help_text='Číslo/kód úlohy v čísle nebo kód tématu/článku/seriálu v ročníku') vytvoreno = models.DateTimeField('vytvořeno', default=timezone.now, blank=True, editable=False) def __str__(self): return self.nazev # Implicitini implementace, jednotlivé dědící třídy si přepíšou @cached_property def kod_v_rocniku(self): if self.stav == Problem.STAV_ZADANY or self.stav == Problem.STAV_VYRESENY: if self.nadproblem: return self.nadproblem.kod_v_rocniku+".{}".format(self.kod) return str(self.kod) logger.warning(f"K problému {self} byl vyžadován kód v ročníku, i když není zadaný ani vyřešený.") return '' # def verejne(self): # # aktuálně podle stavu problému # # FIXME pro některé problémy možná chceme override # # FIXME vrací veřejnost čistě problému, nezávisle na čísle, ve kterém je. # # Je to tak správně? Podle aktuální představy ano. # stav_verejny = False # if self.stav == 'zadany' or self.stav == 'vyreseny': # stav_verejny = True # print("stav_verejny: {}".format(stav_verejny)) # # cislo_verejne = False # cislonode = self.cislo_node() # if cislonode is None: # # problém nemá vlastní node, veřejnost posuzujeme jen podle stavu # print("empty node") # return stav_verejny # else: # cislo_zadani = cislonode.cislo # if (cislo_zadani and cislo_zadani.verejne()): # print("cislo: {}".format(cislo_zadani)) # cislo_verejne = True # print("stav_verejny: {}".format(stav_verejny)) # print("cislo_verejne: {}".format(cislo_verejne)) # return (stav_verejny and cislo_verejne) # verejne.boolean = True def verejne_url(self): return reverse('seminar_problem', kwargs={'pk': self.id}) def admin_url(self): return reverse('admin:seminar_problem_change', args=(self.id, )) @cached_property def hlavni_problem(self): """ Pro daný problém vrátí jeho nejvyšší nadproblém.""" problem = self while not (problem.nadproblem is None): problem = problem.nadproblem return problem # FIXME - k úloze def body_v_zavorce(self): """Vrať string s body v závorce jsou-li u problému vyplněné, jinak '' Je-li desetinná část nulová, nezobrazuj ji. """ pocet_bodu = None if self.body: b = self.body pocet_bodu = int(b) if int(b) == b else b return "({}\u2009b)".format(pocet_bodu) if self.body else "" class Tema(Problem): class Meta: db_table = 'seminar_temata' verbose_name = 'Téma' verbose_name_plural = 'Témata' TEMA_TEMA = 'tema' TEMA_SERIAL = 'serial' TEMA_CHOICES = [ (TEMA_TEMA, 'Téma'), (TEMA_SERIAL, 'Seriál'), ] tema_typ = models.CharField('Typ tématu', max_length=16, choices=TEMA_CHOICES, blank=False, default=TEMA_TEMA) rocnik = models.ForeignKey(Rocnik, verbose_name='ročník',related_name='temata',blank=True, null=True, on_delete=models.PROTECT) abstrakt = models.TextField('Abstrakt na rozcestník', blank=True) obrazek = models.ImageField('Obrázek na rozcestník', null=True, blank=True) @cached_property def kod_v_rocniku(self): if self.stav == Problem.STAV_ZADANY or self.stav == Problem.STAV_VYRESENY: if self.nadproblem: return self.nadproblem.kod_v_rocniku+".t{}".format(self.kod) return "t{}".format(self.kod) logger.warning(f"K problému {self} byl vyžadován kód v ročníku, i když není zadaný ani vyřešený.") return '' def save(self, *args, **kwargs): super().save(*args, **kwargs) # *Node.save() aktualizuje název *Nodu. for tvcn in self.temavcislenode_set.all(): tvcn.save() def cislo_node(self): tema_node_set = self.temavcislenode_set.all() tema_cisla_vyskyt = [] from seminar.models.treenode import CisloNode for tn in tema_node_set: tema_cisla_vyskyt.append( treelib.get_upper_node_of_type(tn, CisloNode).cislo) tema_cisla_vyskyt.sort(key=lambda x:x.datum_vydani) prvni_zadani = tema_cisla_vyskyt[0] return prvni_zadani.cislonode class Clanek(Problem): class Meta: db_table = 'seminar_clanky' verbose_name = 'Článek' verbose_name_plural = 'Články' cislo = models.ForeignKey(Cislo, blank=True, null=True, on_delete=models.PROTECT, verbose_name='číslo vydání', related_name='vydane_clanky') @cached_property def kod_v_rocniku(self): if self.stav == Problem.STAV_ZADANY or self.stav == Problem.STAV_VYRESENY: # Nemělo by být potřeba # if self.nadproblem: # return self.nadproblem.kod_v_rocniku+".c{}".format(self.kod) return "c{}".format(self.kod) logger.warning(f"K problému {self} byl vyžadován kód v ročníku, i když není zadaný ani vyřešený.") return '' def node(self): return None class Uloha(Problem): class Meta: db_table = 'seminar_ulohy' verbose_name = 'Úloha' verbose_name_plural = 'Úlohy' cislo_zadani = models.ForeignKey(Cislo, verbose_name='číslo zadání', blank=True, null=True, related_name='zadane_ulohy', on_delete=models.PROTECT) cislo_deadline = models.ForeignKey(Cislo, verbose_name='číslo deadlinu', blank=True, null=True, related_name='deadlinove_ulohy', on_delete=models.PROTECT) cislo_reseni = models.ForeignKey(Cislo, verbose_name='číslo řešení', blank=True, null=True, related_name='resene_ulohy', help_text='Číslo s řešením úlohy, jen pro úlohy', on_delete=models.PROTECT) max_body = models.DecimalField(max_digits=8, decimal_places=1, verbose_name='maximum bodů', blank=True, null=True) # má OneToOneField s: # UlohaZadaniNode # UlohaVzorakNode @cached_property def kod_v_rocniku(self): if self.stav == Problem.STAV_ZADANY or self.stav == Problem.STAV_VYRESENY: name="{}.u{}".format(self.cislo_zadani.poradi,self.kod) if self.nadproblem: return self.nadproblem.kod_v_rocniku+name return name logger.warning(f"K problému {self} byl vyžadován kód v ročníku, i když není zadaný ani vyřešený.") return '' def save(self, *args, **kwargs): super().save(*args, **kwargs) # *Node.save() aktualizuje název *Nodu. try: self.ulohazadaninode.save() except ObjectDoesNotExist: # Neexistující *Node nemá smysl aktualizovat. pass try: self.ulohavzoraknode.save() except ObjectDoesNotExist: # Neexistující *Node nemá smysl aktualizovat. pass def cislo_node(self): zadani_node = self.ulohazadaninode from seminar.models.treenode import CisloNode return treelib.get_upper_node_of_type(zadani_node, CisloNode) def aux_generate_filename(self, filename): """Pomocná funkce generující ošetřený název souboru v adresáři s datem""" clean = get_valid_filename( unidecode(filename.replace('/', '-').replace('\0', '')) ) datedir = timezone.now().strftime('%Y-%m') fname = "{}/{}".format( timezone.now().strftime('%Y-%m-%d-%H:%M'), clean) return os.path.join(datedir, fname) class Pohadka(SeminarModelBase): """Kus pohádky před/za úlohou v čísle""" class Meta: db_table = 'seminar_pohadky' verbose_name = 'Pohádka' verbose_name_plural = 'Pohádky' ordering = ['vytvoreno'] # Interní ID id = models.AutoField(primary_key=True) autor = models.ForeignKey( pm.Organizator, verbose_name="Autor pohádky", # Při nahrávání z TeXu není vyplnění vyžadováno, v adminu je null=True, blank=False, on_delete=models.SET_NULL ) vytvoreno = models.DateTimeField( 'Vytvořeno', default=timezone.now, blank=True, editable=False ) # má OneToOneField s: # PohadkaNode def __str__(self): uryvek = self.text if len(self.text) < 50 else self.text[:(50-3)]+"..." return uryvek def save(self, *args, **kwargs): super().save(*args, **kwargs) # *Node.save() aktualizuje název *Nodu. try: self.pohadkanode.save() except ObjectDoesNotExist: # Neexistující *Node nemá smysl aktualizovat. pass @reversion.register(ignore_duplicates=True) class Nastaveni(SingletonModel): class Meta: db_table = 'seminar_nastaveni' verbose_name = 'Nastavení semináře' # aktualni_rocnik = models.ForeignKey(Rocnik, verbose_name='aktuální ročník', # null=False, on_delete=models.PROTECT) aktualni_cislo = models.ForeignKey(Cislo, verbose_name='Aktuální číslo', null=False, on_delete=models.PROTECT) cena_sous = models.IntegerField(null=False, verbose_name="Účastnický poplatek za soustředění", default=1000) @property def aktualni_rocnik(self): return self.aktualni_cislo.rocnik def __str__(self): return 'Nastavení semináře' def admin_url(self): return reverse('admin:seminar_nastaveni_change', args=(self.id, )) def verejne(self): return False