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 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 personalni.models import Prijemce, Organizator from seminar.models.base import SeminarModelBase from various.utils import roman logger = logging.getLogger(__name__) __all__ = [ "Rocnik", "Cislo", "Deadline", "Problem", "Uloha", "Tema", "Clanek", "ZmrazenaVysledkovka", "Pohadka", ] 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 = 'mam_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 = 'mam_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 = settings.NOVE_CISLO_EMAIL predmet = 'Vyšlo číslo {}'.format(self.kod()) # TODO Možná nechceme všem psát „Ahoj“, např. příjemcům… text_mailu = 'Ahoj,\n' \ 'na adrese {} najdete nejnovější číslo.\n' \ 'Vaše M&M\n'.format(odkaz) predmet_prvni = 'Právě vyšlo 1. číslo M&M, pomoz nám ho poslat dál!' text_mailu_prvni = 'Milý řešiteli,\n'\ 'právě jsme na našem webu zveřejnili první číslo {}. ročníku, najdeš ho na tomto odkazu: {}.\n\n'\ 'Doufáme, že tě M&M baví, a byli bychom rádi, kdyby mohlo dělat radost i dalším středoškolákům. Máme na tebe proto jednu prosbu. Sdílej prosím odkaz alespoň s jedním svým kamarádem, který by mohl mít o řešení M&M zájem. Je to pro nás moc důležité a velmi nám tím pomůžeš. Díky!\n\n'\ 'Organizátoři M&M\n'.format(self.rocnik.rocnik, odkaz) predmet_resitel = predmet_prvni if self.poradi == "1" else predmet text_mailu_resitel = text_mailu_prvni if self.poradi == "1" else text_mailu # Prijemci e-mailu from personalni.utils import aktivniResitele resitele_vsichni = aktivniResitele(self).filter(zasilat_cislo_emailem=True) def posli(subject, text, resitele): emaily = map(lambda resitel: resitel.osoba.email, resitele) email = EmailMessage( subject=subject, body=text, from_email=poslat_z_mailu, bcc=list(emaily) #bcc = příjemci skryté kopie ) email.send() paticka = "---\nK odběru těchto e-mailů jste se přihlásili na stránkách https://mam.matfyz.cz. Z odběru se lze odhlásit na https://mam.matfyz.cz/resitel/osobni-udaje/" posli(predmet_resitel, text_mailu_resitel + paticka, resitele_vsichni.filter(zasilat_cislo_papirove=False)) posli(predmet_resitel, text_mailu_resitel + 'P. S. Brzy budeme též rozesílat papírovou verzi čísla. Připomínáme, že pokud papírovou verzi čísla nevyužijete, můžete v https://mam.mff.cuni.cz/resitel/osobni-udaje/ zaškrtnout, abychom vám ji neposílali. Čísla vždy můžete nalézt v našem archivu a dál vám budou chodit e-mailem. Děkujeme.\n' + paticka, resitele_vsichni.filter(zasilat_cislo_papirove=True)) paticka_prijemce = "---\nPokud tyto e-maily nechcete nadále dostávat, prosíme, ozvěte se nám na mam@matfyz.cz." posli(predmet, text_mailu + paticka_prijemce, Prijemce.objects.filter(zasilat_cislo_emailem=True)) 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 treenode.models 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 = 'mam_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 = 'mam_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 = 'mam_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(Organizator, verbose_name='autor problému', related_name='autor_problemu_%(class)s', null=True, blank=True, on_delete=models.SET_NULL) garant = models.ForeignKey(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(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:tvorba_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 = 'mam_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 treenode.models 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 = 'mam_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 = 'mam_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 treenode.models 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 = 'mam_pohadky' verbose_name = 'Pohádka' verbose_name_plural = 'Pohádky' ordering = ['vytvoreno'] # Interní ID id = models.AutoField(primary_key=True) autor = models.ForeignKey( 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