Commit 01e8ab99 authored by GuiΩ's avatar GuiΩ
Browse files

Merge branch 'feature/configuration/latex' into develop

# Conflicts:
#	src/components/FeuilleListFeuilleSeance.vue
parents dc2e4bfd 16f9e4da
......@@ -35,6 +35,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'password_reset',
'rest_framework',
'webpack_loader',
'am',
......@@ -83,6 +84,7 @@ DATABASES = {
'TIME_ZONE': 'Europe/Paris'
}
}
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # to avoid models.W042 warning
# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
......@@ -102,6 +104,18 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
# EMAIL_FILE_PATH = BASE_DIR / "sent_emails"
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = 'smtp.ac-grenoble.fr'
EMAIL_PORT = 465
EMAIL_HOST_USER = 'gclaus@ac-grenoble.fr'
EMAIL_HOST_PASSWORD = 'ZeBulon' #password
DEFAULT_FROM_EMAIL = 'no_reply@ac-grenoble.fr'
EMAIL_USE_TLS = False
EMAIL_USE_SSL = True
# SESSION_EXPIRE_AT_BROWSER_CLOSE = True
# Internationalization
......@@ -123,15 +137,8 @@ USE_TZ = True
# static files :
# where they are
STATICFILES_DIRS = [
# os.path.join(BASE_DIR, "dist"),
os.path.join(BASE_DIR, "static"),
os.path.join(BASE_DIR, "static/"),
]
# where place them
STATIC_ROOT = os.path.join(BASE_DIR, 'public')
# STATICFILES_DIRS = [
# os.path.join(BASE_DIR, "public"),
# ]
# # where place them
# STATIC_ROOT = os.path.join(BASE_DIR, 'static')
......@@ -152,7 +159,7 @@ REST_FRAMEWORK = {
WEBPACK_LOADER = {
'DEFAULT': {
'CACHE': not DEBUG,
'BUNDLE_DIR_NAME': '/bundles/',
'BUNDLE_DIR_NAME': 'bundles/',
'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json'),
'LOADER_CLASS': 'webpack_loader.loader.WebpackLoader',
}
......
......@@ -24,7 +24,7 @@ SECRET_KEY = '7fx7cv_=l3b4^*8wwan^zn0b!v%y6peft0n%*ptvy7%uqp)-qj'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
ALLOWED_HOSTS = ['claus.ddns.net']
ALLOWED_HOSTS = ['am.claus.ovh']
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
......@@ -43,6 +43,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'password_reset',
'rest_framework',
'webpack_loader',
'am',
......@@ -97,9 +98,10 @@ DATABASES = {
},
'TIME_ZONE': 'Europe/Paris'
}
}
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # to avoid models.W042 warning
# Password validation
# https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators
......@@ -118,7 +120,16 @@ AUTH_PASSWORD_VALIDATORS = [
},
]
# SESSION_EXPIRE_AT_BROWSER_CLOSE = True
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = 'smtp.ac-grenoble.fr'
EMAIL_PORT = 465
EMAIL_HOST_USER = 'gclaus@ac-grenoble.fr'
EMAIL_HOST_PASSWORD = 'ZeBulon'
DEFAULT_FROM_EMAIL = 'no_reply@ac-grenoble.fr'
EMAIL_USE_TLS = False
EMAIL_USE_SSL = True
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
......@@ -140,10 +151,10 @@ USE_TZ = True
# where they are
STATICFILES_DIRS = [
# os.path.join(BASE_DIR, "dist"),
os.path.join(BASE_DIR, "static"),
os.path.join(BASE_DIR, "static/"),
]
# where place them
STATIC_ROOT = os.path.join(BASE_DIR, 'public')
# STATIC_ROOT = os.path.join(BASE_DIR, 'public')
# STATICFILES_DIRS = [
# os.path.join(BASE_DIR, "public"),
......@@ -167,8 +178,9 @@ REST_FRAMEWORK = {
WEBPACK_LOADER = {
'DEFAULT': {
'CACHE': not DEBUG,
'BUNDLE_DIR_NAME': '/bundles/',
# 'CACHE': not DEBUG,
'CACHE': False,
'BUNDLE_DIR_NAME': 'bundles/',
'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json'),
'LOADER_CLASS': 'webpack_loader.loader.WebpackLoader',
}
......
......@@ -15,11 +15,11 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
urlpatterns = [
# path('', TemplateView.as_view(template_name='home.html'), name='home'),
# path('', TemplateView.as_view(template_name='base.html'), name='app'),
path('admin/', admin.site.urls),
path('', include('am.urls')),
# path('', RedirectView.as_view(url='am/'))
path("", RedirectView.as_view(url='am/')),
path("am/", include('am.urls', namespace="am")),
path("admin/", admin.site.urls),
]
......@@ -48,9 +48,8 @@ class Feuille(models.Model):
def __str__(self):
return 'Feuille_' + self.classe.nom + '_' + str(self.position)
def compile(self, latexModel):
outfile = re.sub('[^0-9a-zA-z._]', '-', 'am_{}_feuille{}.pdf'.format(self.classe.nom, self.position))
return latexModel.compile(feuille=self, outfile=outfile)
def get_position(self):
return Feuille.objects.filter(classe=self.classe, position__lte=self.position).count()
class Question(models.Model):
......@@ -123,6 +122,10 @@ class Seance(models.Model):
class Meta:
ordering = ['position']
def get_position(self):
return Seance.objects.filter(feuille=self.feuille, position__lte=self.position).count()
class Theme(models.Model):
objects = None
......@@ -172,69 +175,7 @@ class LatexModelType(models.TextChoices):
FIGURE = 'FI' # pour compiler une question à partir de son code
class LatexModel(models.Model):
objects = None
label = models.CharField(max_length=50) # nom du modèle
description = models.TextField() # description du modèle
type = models.CharField(max_length=2, # à quoi s'applique ce modèle ?
choices=LatexModelType.choices)
position = models.IntegerField() # position dans la liste des modèles
delete_tmp = models.BooleanField() # Supprimer les fichiers temporaires après compilation ?
def get_code(self, **kwargs):
mainPart = self.parts.get(type=self.type)
return mainPart.get_code(**kwargs)
# parameters:
# outfile
def compile(self, **kwargs):
tempdir = tempfile.mkdtemp()
texfile = os.path.join(str(tempdir), "file.tex")
pdffile = os.path.join(str(tempdir), "file.pdf")
with open(texfile, "w") as output:
code = self.get_code(**kwargs)
output.write(code)
compilok = True
cmd_tex_array = ["pdflatex", "-interaction=nonstopmode", "-output-directory=" + tempdir, texfile]
# cmd_png_array = ["convert", "-density", "300", "question.pdf", "-quality", "90", temp_dir]
log = ''
try:
# check_call(cmd_tex_array) # pour afficher la sortie de compilation
# check_call(cmd_tex_array, stdout=DEVNULL) # sans affichage
run(cmd_tex_array, check=True, capture_output=True)
baseName = kwargs.get('outfile')[0:-4]
if self.type == LatexModelType.QUESTION:
pdf2image.convert_from_path(
pdffile,
dpi=140,
single_file=True,
transparent=True,
fmt='png',
output_folder='static/thumbs/',
output_file=baseName)
else:
shutil.move(pdffile, 'static/' + kwargs.get('outfile'))
except CalledProcessError as e:
compilok = False
log = e.output
if self.delete_tmp:
shutil.rmtree(tempdir)
return {'status': compilok, 'outfile': kwargs.get('outfile'), 'log': log}
# Code correspondant à une partie du modèle,
# les différentes parties s'incluent les unes dans les autre
# grâce à un système de balises.
class LatexModelPart(models.Model):
model = models.ForeignKey(LatexModel,
on_delete=models.CASCADE,
related_name='parts')
type = models.CharField(max_length=2, # à quoi s'applique cette partie ?
choices=LatexModelType.choices)
code = models.TextField() # code Latex de cette partie
def get_code(self, **kwargs):
return self._replace_vars(self.code, **kwargs)
class VarReplacer(object):
def _replace_vars(self, code, **kwargs):
# TODO: add [[QUESTION_1]] [[QUESTION_2]] ...
......@@ -312,6 +253,7 @@ class LatexModelPart(models.Model):
classe = kwargs.get('classe')
if classe:
code = code.replace("[[CLASSE]]", classe.nom)
code = code.replace("[[CLASSE_LATEX]]", classe.nom_latex)
# code = code.replace("[[NIVEAU]]", classe.niveau.label)
annee_sco = "{}-{}".format(classe.annee, classe.annee + 1)
code = code.replace("[[ANNEE_SCO]]", annee_sco)
......@@ -356,14 +298,14 @@ class LatexModelPart(models.Model):
def _replace_feuille(self, code, **kwargs):
feuille = kwargs.get('feuille')
code = code.replace("[[NFEUILLE]]", str(feuille.position))
code = code.replace("[[NFEUILLE]]", str(feuille.get_position()))
match = re.search("\\[\\[NFEUILLE_(\\d+)\\]\\]", code)
while match is not None:
ndigits = match.group(1)
code = code.replace(
"[[NFEUILLE_{}]]".format(ndigits),
("{" + ":0{}d".format(ndigits) + "}")
.format(feuille.position))
.format(feuille.get_position()))
match = re.search("\\[\\[NFEUILLE_(\\d+)\\]\\]", code)
if code.find("[[SEANCES]]") >= 0:
temp = ''
......@@ -377,12 +319,12 @@ class LatexModelPart(models.Model):
def _replace_seance(self, code, **kwargs):
seance = kwargs.get('seance')
code = code.replace("[[NSEANCE]]", str(seance.position))
code = code.replace("[[NSEANCE]]", str(seance.get_position()))
match = re.search("\\[\\[NSEANCE_(\\d+)\\]\\]", code)
while match is not None:
ndigits = match.group(1)
code = code.replace("[[NSEANCE_{}]]".format(ndigits),
("{" + ":0{}d".format(ndigits) + "}").format(seance.position))
("{" + ":0{}d".format(ndigits) + "}").format(seance.get_position()))
match = re.search("\\[\\[NSEANCE_(\\d+)\\]\\]", code)
if code.find("[[QUESTIONS]]") >= 0:
temp = ''
......@@ -415,3 +357,74 @@ class LatexModelPart(models.Model):
("{" + ":0{}d".format(ndigits) + "}").format(question.id))
match = re.search("\\[\\[IDQUESTION_(\\d+)\\]\\]", code)
return code.replace("[[QUESTION]]", question.code)
class LatexModel(models.Model, VarReplacer):
objects = None
label = models.CharField(max_length=50) # nom du modèle
description = models.TextField(blank=True, null=True) # description du modèle
type = models.CharField(max_length=2, # à quoi s'applique ce modèle ?
choices=LatexModelType.choices)
file_name = models.CharField(max_length=255, blank=True, null=True)
position = models.IntegerField(blank=True, null=True) # position dans la liste des modèles
delete_tmp = models.BooleanField(default=True) # Supprimer les fichiers temporaires après compilation ?
def get_code(self, **kwargs):
mainPart = self.parts.get(type=self.type)
return mainPart.get_code(**kwargs)
def get_file_name(self, **kwargs):
return self._replace_vars(self.file_name, **kwargs)
def compile(self, **kwargs):
tempdir = tempfile.mkdtemp()
texfile = os.path.join(str(tempdir), "file.tex")
pdffile = os.path.join(str(tempdir), "file.pdf")
with open(texfile, "w") as output:
code = self.get_code(**kwargs)
output.write(code)
compilok = True
cmd_tex_array = ["pdflatex", "-interaction=nonstopmode", "-output-directory=" + tempdir, texfile]
# cmd_png_array = ["convert", "-density", "300", "question.pdf", "-quality", "90", temp_dir]
log = ''
outfile = kwargs.get('outfile')
if outfile:
baseName = kwargs.get('outfile')[0:-4]
else:
baseName = self._replace_vars(self.file_name, **kwargs)
outfile += '.pdf'
try:
# check_call(cmd_tex_array) # pour afficher la sortie de compilation
# check_call(cmd_tex_array, stdout=DEVNULL) # sans affichage
run(cmd_tex_array, check=True, capture_output=True)
if self.type == LatexModelType.QUESTION:
pdf2image.convert_from_path(
pdffile,
dpi=140,
single_file=True,
transparent=True,
fmt='png',
output_folder='static/thumbs/',
output_file=baseName)
else:
shutil.move(pdffile, 'static/' + outfile)
except CalledProcessError as e:
compilok = False
log = e.output
if self.delete_tmp:
shutil.rmtree(tempdir)
return {'status': compilok, 'outfile': outfile, 'latexlog': log}
# Code correspondant à une partie du modèle,
# les différentes parties s'incluent les unes dans les autre
# grâce à un système de balises.
class LatexModelPart(models.Model, VarReplacer):
model = models.ForeignKey(LatexModel,
on_delete=models.CASCADE,
related_name='parts')
type = models.CharField(max_length=2, # à quoi s'applique cette partie ?
choices=LatexModelType.choices)
code = models.TextField(blank=True, null=True) # code Latex de cette partie
def get_code(self, **kwargs):
return self._replace_vars(self.code, **kwargs)
......@@ -26,18 +26,6 @@ class ClasseSerializer(ModelSerializer):
return data
class LatexModelPartSerializer(ModelSerializer):
class Meta:
model = LatexModelPart
fields = ['id', 'code', 'type']
class LatexModelSerializer(ModelSerializer):
class Meta:
model = LatexModel
fields = ['id', 'label', 'description', 'position', 'type', 'parts']
class LatexModelPartSerializer(ModelSerializer):
class Meta:
model = LatexModelPart
......@@ -47,7 +35,7 @@ class LatexModelPartSerializer(ModelSerializer):
class LatexModelSerializer(ModelSerializer):
class Meta:
model = LatexModel
fields = ['id', 'label', 'description', 'position', 'type', 'parts']
fields = ['id', 'label', 'description', 'file_name', 'position', 'type', 'parts']
class QuestionSerializer(ModelSerializer):
......@@ -98,7 +86,7 @@ class QuestionSerializer(ModelSerializer):
os.remove(old_thumb_path)
else:
errors['code'] = "Erreur de compilation"
errors['latexlog'] = result['log']
errors['latexlog'] = result['latexlog']
# Avalider possible only if the position is set for all the themes
avalider = data.get('avalider')
......
import os
from django.test import TestCase
from am.models import Utilisateur, Question, Theme, Seance, QuestionPosee, QuestionOrder
......@@ -99,18 +101,19 @@ class QuestionViewSetTestCase(TestCase):
def test_create(self):
question = {
'code': '$3\\times4$',
'code': '$3\\times 4$',
'themes': []
}
question['themes'].append(self.soustheme21.id)
response = self.client.post('/api/question/', question)
if 'latexlog' in response.json():
print(response.json()['latexlog'])
self.assertEqual(Question.objects.get(pk=response.json()['id']).auteur, self.user1)
self.assertEqual(Question.objects.get(pk=response.data['id']).auteur, self.user1)
self.assertTrue(os.path.isfile('static/thumbs/' + response.data['thumb_name']))
def test_create_avalider(self):
question = {
'code': '$3\\times4$',
'code': '$3\\times 4$',
'themes': [],
'avalider': True,
}
......@@ -120,7 +123,7 @@ class QuestionViewSetTestCase(TestCase):
def test_create_validee(self):
question = {
'code': '$3\\times4$',
'code': '$3\\times 4$',
'themes': []
}
question['themes'].append(self.soustheme21.id)
......@@ -178,6 +181,7 @@ class QuestionViewSetTestCase(TestCase):
self.assertEqual(Question.objects.get(pk=self.question1.id).code, question['code'])
self.assertEqual(list(Question.objects.get(pk=self.question1.id).themes.values_list('id', flat=True)).sort(),
question['themes'].sort())
self.assertTrue(os.path.isfile('static/thumbs/' + response.data['thumb_name']))
def test_patch_not_owned(self):
question = {
......@@ -304,7 +308,7 @@ class QuestionViewSetTestCase(TestCase):
response = self.client.patch('/api/question/{}/'.format(self.question1.id),
question,
content_type='application/json')
self.assertIn(response.status_code, {400, 401, 403}, str(response.json()))
self.assertGreaterEqual(response.status_code, 400, str(response.json()))
self.assertEqual(Question.objects.get(pk=self.question1.id).code, self.question1.code)
def test_patch_without_theme(self):
......
from django.conf.urls import url
from django.urls import include
from django.urls import include, path, reverse_lazy
from django.views.generic import TemplateView
from rest_framework.routers import SimpleRouter
from django.contrib.auth import views as auth_views
from am import views
......@@ -20,9 +20,18 @@ router.register(r'seancer', views.SeanceViewSetR, 'seancer')
router.register(r'theme', views.ThemeViewSet, 'theme')
urlpatterns = [
url('', include('django.contrib.auth.urls')),
url('^signup/', views.SignUpView.as_view(), name='signup'),
url('^api/', include((router.urls, app_name))),
url('^api/annee_list/', views.AnneeView.as_view()),
url(r'^.*$', TemplateView.as_view(template_name='home.html'), name='home'),
path('', TemplateView.as_view(template_name='home.html'), name='home'),
path('password_reset/', auth_views.PasswordResetView.as_view(
success_url=reverse_lazy('am:password_reset_done'),
),
name='password_reset'
),
path('reset/<uidb64>/<token>/',
auth_views.PasswordResetConfirmView.as_view(success_url=reverse_lazy('am:password_reset_complete')),
name='password_reset_confirm'
),
path('', include('django.contrib.auth.urls')),
path('signup/', views.SignUpView.as_view(), name='signup'),
path('api/', include((router.urls, app_name))),
path('api/annee_list/', views.AnneeView.as_view()),
]
......@@ -12,7 +12,7 @@ from rest_framework.viewsets import ModelViewSet
from am import serializers
from .forms import UtilisateurCreationForm
from .models import Question, Classe, Feuille, Theme, QuestionPosee, Seance, LatexModelType, LatexModel, QuestionOrder, \
from .models import Question, Classe, Feuille, Theme, QuestionPosee, Seance, LatexModel, QuestionOrder, \
LatexModelPart
from .serializers import SeanceSerializerR, ThemeSerializer, LatexModelSerializer, \
LatexModelPartSerializer
......@@ -33,7 +33,6 @@ class AnneeView(View):
def get(request):
annees = []
annee = None
print(request.user)
for val in Classe.objects.filter(enseignant=request.user).order_by('annee').values('annee'):
if val != annee:
annees.append(val['annee'])
......@@ -92,17 +91,15 @@ class FeuilleViewSet(ModelViewSet):
@action(detail=True, methods=['get'])
def get_pdf(self, request, pk):
latexModel = LatexModel.objects.get(type=LatexModelType.FEUILLE)
latexModel = LatexModel.objects.get(pk=request.query_params['model_id'])
feuille = self.get_object()
print(feuille)
result = feuille.compile(latexModel)
result = latexModel.compile(feuille=feuille)
if not result['status']:
return HttpResponse(result['message'].decode('utf-8').replace('\n', '<br>'))
return HttpResponse(result['latexlog'].decode('utf-8').replace('\n', '<br>'))
else:
path = 'static/' + result['outfile']
return FileResponse(open(path, 'rb'), as_attachment=True, content_type='application/pdf',
filename=feuille.classe.nom.replace(' ', '-') + "_feuille_" + str(
feuille.position) + '.pdf')
filename=result['outfile'])
class LatexModelViewSet(ModelViewSet):
......@@ -259,6 +256,18 @@ class SeanceViewSet(ModelViewSet):
else:
return super().get_object()
@action(detail=True, methods=['get'])
def get_pdf(self, request, pk):
latexModel = LatexModel.objects.get(pk=request.query_params['model_id'])
seance = self.get_object()
result = latexModel.compile(seance=seance)
if not result['status']:
return HttpResponse(result['latexlog'].decode('utf-8').replace('\n', '<br>'))
else:
path = 'static/' + result['outfile']
return FileResponse(open(path, 'rb'), as_attachment=True, content_type='application/pdf',
filename=latexModel.get_file_name(seance=seance) + '.pdf')
# def perform_create(self, serializer):
# print(self.request)
# newPosition = Seance.objects.filter(feuille=self.request.data['feuille']).aggregate(Max('position'))['position__max']
......
......@@ -9,6 +9,7 @@ export default {
name: 'app',
created() {
this.$store.dispatch('themes/loadThemes');
this.$store.dispatch('latex/loadModels');
},
}
</script>
\ No newline at end of file
......@@ -66,9 +66,13 @@ export default {
<style scoped>
#config-classes {
display: table;
align-content: center;
border: solid 1px;
/*display: table-cell;*/
/*align-content: center;*/
/*border: solid 1px;*/
width: 100%;
/*margin: auto;*/
}
#config-classes table{
margin: auto;
}
......
<template>
<div>
<h3>Modèles LaTeX:</h3>
<button
v-for="model of models"
v-bind:key="model.id"
v-bind:class="['tab-button', { active: currentModel === model }]"
v-on:click="currentModel = model">
{{ model.label }}
</button>
<template v-for="model of models">
<button
v-if="model.id || newModel"
v-bind:key="model.id"
v-bind:class="['tab-button', { active: currentModel === model }]"
v-on:click="currentModel = model">
{{ model.label }}
</button>
</template>
<button @click="newModel = true; currentModel = models[null]" :disabled="newModel">+</button>
<keep-alive>
<config-latex-model v-if="currentModel" :model_id="model_id" class="tab"
@message="$refs.messageBox.show($event)"></config-latex-model>
......@@ -26,11 +29,14 @@ export default {
data() {
return {
currentModel: null,
newModel: false,
}
},
computed: {
models() {
return this.$store.state.latex.models
let models = {...this.$store.state.latex.models }
models[null] = {id:null, label:'Nouveau Modèle'}
return models
},
model_id() {
return this.currentModel.id
......
......@@ -4,24 +4,38 @@
<table>
<tr>
<td>Nom :</td>
<td><input v-if="model" v-model="model.label" :disabled="!editable" @input="$refs.saveBtn.disabled = false"/>
<td><input v-if="model"