Jonas Havelka
3 years ago
26 changed files with 599 additions and 534 deletions
@ -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? |
|||
""" |
@ -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) |
@ -0,0 +1,5 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class OdevzdavatkoConfig(AppConfig): |
|||
name = 'odevzdavatko' |
@ -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) |
Before Width: | Height: | Size: 717 B After Width: | Height: | Size: 717 B |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@ -1,7 +1,7 @@ |
|||
{% extends "base.html" %} |
|||
{% load staticfiles %} |
|||
{% block script %} |
|||
<script src="{% static 'seminar/dynamic_formsets.js' %}"></script> |
|||
<script src="{% static 'odevzdavatko/dynamic_formsets.js' %}"></script> |
|||
{% endblock %} |
|||
|
|||
{% block content %} |
@ -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/<int:rocnik>/', org_required(views.TabulkaOdevzdanychReseniView.as_view()), name='odevzdavatko_tabulka'), |
|||
path('org/reseni/<int:problem>/<int:resitel>/', org_required(views.ReseniProblemuView.as_view()), name='odevzdavatko_reseni_resitele_k_problemu'), |
|||
path('org/reseni/<int:pk>', 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())), |
|||
] |
@ -0,0 +1,2 @@ |
|||
from .models_all import * |
|||
from .odevzdavatko import * |
@ -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) |
|||
|
@ -1,6 +1,5 @@ |
|||
from .views_all import * |
|||
from .views_rest import * |
|||
from .odevzdavatko import * |
|||
|
|||
# Dočsasné views |
|||
from .docasne import * |
|||
|
Loading…
Reference in new issue