feat: improve assessment creation and edition
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Configuration de l'application
|
||||||
|
FLASK_ENV=development
|
||||||
|
SECRET_KEY=your-very-secure-secret-key-here-change-this
|
||||||
|
DATABASE_URL=sqlite:///school_management.db
|
||||||
|
|
||||||
|
# Base de données
|
||||||
|
DB_ECHO=false
|
||||||
|
|
||||||
|
# Optionnel: pour la production
|
||||||
|
# FLASK_ENV=production
|
||||||
|
# DATABASE_URL=postgresql://user:password@localhost/dbname
|
||||||
73
app.py
73
app.py
@@ -1,17 +1,35 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
from flask import Flask, render_template
|
from flask import Flask, render_template
|
||||||
from models import db, Assessment, Student, ClassGroup
|
from models import db, Assessment, Student, ClassGroup
|
||||||
from commands import init_db
|
from commands import init_db
|
||||||
|
from config import config
|
||||||
|
|
||||||
# Import blueprints
|
# Import blueprints
|
||||||
from routes.assessments import bp as assessments_bp
|
from routes.assessments import bp as assessments_bp
|
||||||
from routes.exercises import bp as exercises_bp
|
from routes.exercises import bp as exercises_bp
|
||||||
from routes.grading import bp as grading_bp
|
from routes.grading import bp as grading_bp
|
||||||
|
|
||||||
def create_app():
|
def create_app(config_name=None):
|
||||||
|
if config_name is None:
|
||||||
|
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['SECRET_KEY'] = 'your-secret-key-here'
|
app.config.from_object(config[config_name])
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///school_management.db'
|
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
# Configuration du logging
|
||||||
|
if not app.debug and not app.testing:
|
||||||
|
if not os.path.exists('logs'):
|
||||||
|
os.mkdir('logs')
|
||||||
|
|
||||||
|
file_handler = logging.FileHandler('logs/school_management.log')
|
||||||
|
file_handler.setFormatter(logging.Formatter(
|
||||||
|
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
|
||||||
|
))
|
||||||
|
file_handler.setLevel(logging.INFO)
|
||||||
|
app.logger.addHandler(file_handler)
|
||||||
|
app.logger.setLevel(logging.INFO)
|
||||||
|
app.logger.info('Application de gestion scolaire démarrée')
|
||||||
|
|
||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
@@ -27,25 +45,41 @@ def create_app():
|
|||||||
# Main routes
|
# Main routes
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index():
|
def index():
|
||||||
recent_assessments = Assessment.query.order_by(Assessment.date.desc()).limit(5).all()
|
try:
|
||||||
total_students = Student.query.count()
|
recent_assessments = Assessment.query.order_by(Assessment.date.desc()).limit(5).all()
|
||||||
total_assessments = Assessment.query.count()
|
total_students = Student.query.count()
|
||||||
total_classes = ClassGroup.query.count()
|
total_assessments = Assessment.query.count()
|
||||||
return render_template('index.html',
|
total_classes = ClassGroup.query.count()
|
||||||
recent_assessments=recent_assessments,
|
return render_template('index.html',
|
||||||
total_students=total_students,
|
recent_assessments=recent_assessments,
|
||||||
total_assessments=total_assessments,
|
total_students=total_students,
|
||||||
total_classes=total_classes)
|
total_assessments=total_assessments,
|
||||||
|
total_classes=total_classes)
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f'Erreur lors du chargement de la page d\'accueil: {e}')
|
||||||
|
return render_template('error.html', error="Erreur lors du chargement de la page"), 500
|
||||||
|
|
||||||
@app.route('/classes')
|
@app.route('/classes')
|
||||||
def classes():
|
def classes():
|
||||||
classes = ClassGroup.query.order_by(ClassGroup.year, ClassGroup.name).all()
|
try:
|
||||||
return render_template('classes.html', classes=classes)
|
classes = ClassGroup.query.order_by(ClassGroup.year, ClassGroup.name).all()
|
||||||
|
return render_template('classes.html', classes=classes)
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f'Erreur lors du chargement des classes: {e}')
|
||||||
|
return render_template('error.html', error="Erreur lors du chargement des classes"), 500
|
||||||
|
|
||||||
@app.route('/students')
|
@app.route('/students')
|
||||||
def students():
|
def students():
|
||||||
students = Student.query.join(ClassGroup).order_by(ClassGroup.name, Student.last_name, Student.first_name).all()
|
try:
|
||||||
return render_template('students.html', students=students)
|
# Optimisation: utiliser joinedload pour éviter les requêtes N+1
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
students = Student.query.options(joinedload(Student.class_group)).order_by(
|
||||||
|
ClassGroup.name, Student.last_name, Student.first_name
|
||||||
|
).join(ClassGroup).all()
|
||||||
|
return render_template('students.html', students=students)
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f'Erreur lors du chargement des étudiants: {e}')
|
||||||
|
return render_template('error.html', error="Erreur lors du chargement des étudiants"), 500
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
@@ -53,4 +87,7 @@ if __name__ == '__main__':
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
app.run(debug=True)
|
|
||||||
|
# Le mode debug est géré par la configuration
|
||||||
|
port = int(os.environ.get('PORT', 5000))
|
||||||
|
app.run(host='0.0.0.0', port=port)
|
||||||
44
config.py
Normal file
44
config.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration de base"""
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY') or os.urandom(32)
|
||||||
|
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///school_management.db'
|
||||||
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
|
WTF_CSRF_TIME_LIMIT = timedelta(hours=1)
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
"""Configuration pour le développement"""
|
||||||
|
DEBUG = True
|
||||||
|
SQLALCHEMY_ECHO = os.environ.get('DB_ECHO', 'False').lower() == 'true'
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
"""Configuration pour la production"""
|
||||||
|
DEBUG = False
|
||||||
|
SQLALCHEMY_ECHO = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def init_app(cls, app):
|
||||||
|
Config.init_app(app)
|
||||||
|
|
||||||
|
# Log vers stderr en production
|
||||||
|
import logging
|
||||||
|
from logging import StreamHandler
|
||||||
|
file_handler = StreamHandler()
|
||||||
|
file_handler.setLevel(logging.WARNING)
|
||||||
|
app.logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
class TestingConfig(Config):
|
||||||
|
"""Configuration pour les tests"""
|
||||||
|
TESTING = True
|
||||||
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||||
|
WTF_CSRF_ENABLED = False
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'development': DevelopmentConfig,
|
||||||
|
'production': ProductionConfig,
|
||||||
|
'testing': TestingConfig,
|
||||||
|
'default': DevelopmentConfig
|
||||||
|
}
|
||||||
17
forms.py
17
forms.py
@@ -33,18 +33,5 @@ class StudentForm(FlaskForm):
|
|||||||
super(StudentForm, self).__init__(*args, **kwargs)
|
super(StudentForm, self).__init__(*args, **kwargs)
|
||||||
self.class_group_id.choices = [(cg.id, cg.name) for cg in ClassGroup.query.order_by(ClassGroup.name).all()]
|
self.class_group_id.choices = [(cg.id, cg.name) for cg in ClassGroup.query.order_by(ClassGroup.name).all()]
|
||||||
|
|
||||||
class ExerciseForm(FlaskForm):
|
# Formulaires ExerciseForm et GradingElementForm supprimés
|
||||||
title = StringField('Titre', validators=[DataRequired(), Length(max=200)])
|
# Ces éléments sont maintenant gérés via le formulaire unifié AssessmentForm
|
||||||
description = TextAreaField('Description', validators=[Optional()])
|
|
||||||
order = IntegerField('Ordre', validators=[DataRequired(), NumberRange(min=1)], default=1)
|
|
||||||
submit = SubmitField('Enregistrer')
|
|
||||||
|
|
||||||
class GradingElementForm(FlaskForm):
|
|
||||||
label = StringField('Libellé', validators=[DataRequired(), Length(max=200)])
|
|
||||||
description = TextAreaField('Description', validators=[Optional()])
|
|
||||||
skill = StringField('Compétence', validators=[Optional(), Length(max=200)])
|
|
||||||
max_points = FloatField('Barème (points max)', validators=[DataRequired(), NumberRange(min=0.1)], default=1.0)
|
|
||||||
grading_type = SelectField('Type de notation', validators=[DataRequired()],
|
|
||||||
choices=[('points', 'Points (ex: 2.5/4)'), ('score', 'Score (0, 1, 2, 3, .)')],
|
|
||||||
default='points')
|
|
||||||
submit = SubmitField('Enregistrer')
|
|
||||||
18
models.py
18
models.py
@@ -1,5 +1,7 @@
|
|||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from sqlalchemy import Index, CheckConstraint
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
|
||||||
@@ -10,6 +12,7 @@ class ClassGroup(db.Model):
|
|||||||
year = db.Column(db.String(20), nullable=False)
|
year = db.Column(db.String(20), nullable=False)
|
||||||
students = db.relationship('Student', backref='class_group', lazy=True)
|
students = db.relationship('Student', backref='class_group', lazy=True)
|
||||||
assessments = db.relationship('Assessment', backref='class_group', lazy=True)
|
assessments = db.relationship('Assessment', backref='class_group', lazy=True)
|
||||||
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<ClassGroup {self.name}>'
|
return f'<ClassGroup {self.name}>'
|
||||||
@@ -24,6 +27,10 @@ class Student(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Student {self.first_name} {self.last_name}>'
|
return f'<Student {self.first_name} {self.last_name}>'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self):
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
class Assessment(db.Model):
|
class Assessment(db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
@@ -31,7 +38,7 @@ class Assessment(db.Model):
|
|||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
|
date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
|
||||||
class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False)
|
class_group_id = db.Column(db.Integer, db.ForeignKey('class_group.id'), nullable=False)
|
||||||
coefficient = db.Column(db.Float, default=1.0)
|
coefficient = db.Column(db.Float, default=1.0) # Garder Float pour compatibilité
|
||||||
exercises = db.relationship('Exercise', backref='assessment', lazy=True, cascade='all, delete-orphan')
|
exercises = db.relationship('Exercise', backref='assessment', lazy=True, cascade='all, delete-orphan')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -54,8 +61,8 @@ class GradingElement(db.Model):
|
|||||||
label = db.Column(db.String(200), nullable=False)
|
label = db.Column(db.String(200), nullable=False)
|
||||||
description = db.Column(db.Text)
|
description = db.Column(db.Text)
|
||||||
skill = db.Column(db.String(200))
|
skill = db.Column(db.String(200))
|
||||||
max_points = db.Column(db.Float, nullable=False)
|
max_points = db.Column(db.Float, nullable=False) # Garder Float pour compatibilité
|
||||||
grading_type = db.Column(db.String(10), nullable=False, default='points') # 'score' or 'points'
|
grading_type = db.Column(db.String(10), nullable=False, default='points')
|
||||||
grades = db.relationship('Grade', backref='grading_element', lazy=True, cascade='all, delete-orphan')
|
grades = db.relationship('Grade', backref='grading_element', lazy=True, cascade='all, delete-orphan')
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -65,8 +72,9 @@ class Grade(db.Model):
|
|||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
student_id = db.Column(db.Integer, db.ForeignKey('student.id'), nullable=False)
|
student_id = db.Column(db.Integer, db.ForeignKey('student.id'), nullable=False)
|
||||||
grading_element_id = db.Column(db.Integer, db.ForeignKey('grading_element.id'), nullable=False)
|
grading_element_id = db.Column(db.Integer, db.ForeignKey('grading_element.id'), nullable=False)
|
||||||
value = db.Column(db.String(10)) # String to handle scores (0,1,2,3,.) and points
|
value = db.Column(db.String(10)) # Garder l'ancien format pour compatibilité
|
||||||
comment = db.Column(db.Text)
|
comment = db.Column(db.Text)
|
||||||
|
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f'<Grade {self.value} for {self.student.first_name}>'
|
return f'<Grade {self.value} for {self.student.first_name if self.student else "Unknown"}>'
|
||||||
@@ -1,55 +1,153 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, current_app
|
||||||
from models import db, Assessment, ClassGroup
|
from models import db, Assessment, ClassGroup
|
||||||
from forms import AssessmentForm
|
from forms import AssessmentForm
|
||||||
|
from services import AssessmentService
|
||||||
|
from utils import handle_db_errors, ValidationError
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
bp = Blueprint('assessments', __name__, url_prefix='/assessments')
|
bp = Blueprint('assessments', __name__, url_prefix='/assessments')
|
||||||
|
|
||||||
@bp.route('/')
|
@bp.route('/')
|
||||||
|
@handle_db_errors
|
||||||
def list():
|
def list():
|
||||||
assessments = Assessment.query.join(ClassGroup).order_by(Assessment.date.desc()).all()
|
from sqlalchemy.orm import joinedload
|
||||||
|
assessments = Assessment.query.options(joinedload(Assessment.class_group)).order_by(Assessment.date.desc()).all()
|
||||||
return render_template('assessments.html', assessments=assessments)
|
return render_template('assessments.html', assessments=assessments)
|
||||||
|
|
||||||
@bp.route('/new', methods=['GET', 'POST'])
|
# Route obsolète supprimée - utiliser new_unified à la place
|
||||||
def new():
|
|
||||||
form = AssessmentForm()
|
|
||||||
if form.validate_on_submit():
|
|
||||||
assessment = Assessment(
|
|
||||||
title=form.title.data,
|
|
||||||
description=form.description.data,
|
|
||||||
date=form.date.data,
|
|
||||||
class_group_id=form.class_group_id.data,
|
|
||||||
coefficient=form.coefficient.data
|
|
||||||
)
|
|
||||||
db.session.add(assessment)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Évaluation créée avec succès !', 'success')
|
|
||||||
return redirect(url_for('assessments.detail', id=assessment.id))
|
|
||||||
return render_template('assessment_form.html', form=form, title='Nouvelle évaluation')
|
|
||||||
|
|
||||||
@bp.route('/<int:id>')
|
@bp.route('/<int:id>')
|
||||||
|
@handle_db_errors
|
||||||
def detail(id):
|
def detail(id):
|
||||||
assessment = Assessment.query.get_or_404(id)
|
from sqlalchemy.orm import joinedload
|
||||||
|
from models import Exercise, GradingElement
|
||||||
|
assessment = Assessment.query.options(
|
||||||
|
joinedload(Assessment.class_group),
|
||||||
|
joinedload(Assessment.exercises).joinedload(Exercise.grading_elements)
|
||||||
|
).get_or_404(id)
|
||||||
return render_template('assessment_detail.html', assessment=assessment)
|
return render_template('assessment_detail.html', assessment=assessment)
|
||||||
|
|
||||||
|
def _handle_unified_assessment_request(form, assessment=None, is_edit=False):
|
||||||
|
"""Fonction helper pour traiter les requêtes JSON d'évaluation unifiée"""
|
||||||
|
# Ne traiter que les requêtes POST
|
||||||
|
if request.method != 'POST':
|
||||||
|
return None
|
||||||
|
|
||||||
|
if request.is_json:
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({'success': False, 'error': 'Aucune donnée fournie'}), 400
|
||||||
|
|
||||||
|
# Peupler le formulaire pour validation CSRF
|
||||||
|
form.csrf_token.data = data.get('csrf_token')
|
||||||
|
|
||||||
|
# Traitement via le service
|
||||||
|
if is_edit and assessment:
|
||||||
|
processed_assessment = AssessmentService.process_assessment_with_exercises(
|
||||||
|
data, is_edit=True, existing_assessment=assessment
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
processed_assessment = AssessmentService.process_assessment_with_exercises(data)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash('Évaluation ' + ('modifiée' if is_edit else 'créée') + ' avec succès !', 'success')
|
||||||
|
return jsonify({'success': True, 'assessment_id': processed_assessment.id})
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
current_app.logger.warning(f'Erreur de validation: {e}')
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
except ValueError as e:
|
||||||
|
current_app.logger.warning(f'Erreur de données: {e}')
|
||||||
|
return jsonify({'success': False, 'error': str(e)}), 400
|
||||||
|
|
||||||
|
else: # request.method == 'POST' and not request.is_json
|
||||||
|
# Traitement classique du formulaire (fallback)
|
||||||
|
if form.validate_on_submit():
|
||||||
|
if is_edit and assessment:
|
||||||
|
AssessmentService.update_assessment_basic_info(assessment, {
|
||||||
|
'title': form.title.data,
|
||||||
|
'description': form.description.data,
|
||||||
|
'date': form.date.data,
|
||||||
|
'class_group_id': form.class_group_id.data,
|
||||||
|
'coefficient': form.coefficient.data
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
assessment = AssessmentService.create_assessment({
|
||||||
|
'title': form.title.data,
|
||||||
|
'description': form.description.data,
|
||||||
|
'date': form.date.data,
|
||||||
|
'class_group_id': form.class_group_id.data,
|
||||||
|
'coefficient': form.coefficient.data
|
||||||
|
})
|
||||||
|
db.session.commit()
|
||||||
|
flash('Évaluation ' + ('modifiée' if is_edit else 'créée') + ' avec succès !', 'success')
|
||||||
|
return redirect(url_for('assessments.detail', id=assessment.id))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
@bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
@bp.route('/<int:id>/edit', methods=['GET', 'POST'])
|
||||||
|
@handle_db_errors
|
||||||
def edit(id):
|
def edit(id):
|
||||||
assessment = Assessment.query.get_or_404(id)
|
from sqlalchemy.orm import joinedload
|
||||||
|
from models import Exercise, GradingElement
|
||||||
|
assessment = Assessment.query.options(
|
||||||
|
joinedload(Assessment.class_group),
|
||||||
|
joinedload(Assessment.exercises).joinedload(Exercise.grading_elements)
|
||||||
|
).get_or_404(id)
|
||||||
form = AssessmentForm(obj=assessment)
|
form = AssessmentForm(obj=assessment)
|
||||||
if form.validate_on_submit():
|
|
||||||
assessment.title = form.title.data
|
result = _handle_unified_assessment_request(form, assessment, is_edit=True)
|
||||||
assessment.description = form.description.data
|
if result:
|
||||||
assessment.date = form.date.data
|
return result
|
||||||
assessment.class_group_id = form.class_group_id.data
|
|
||||||
assessment.coefficient = form.coefficient.data
|
# Préparer les exercices pour la sérialisation JSON
|
||||||
db.session.commit()
|
exercises_data = []
|
||||||
flash('Évaluation modifiée avec succès !', 'success')
|
for exercise in assessment.exercises:
|
||||||
return redirect(url_for('assessments.detail', id=assessment.id))
|
exercise_data = {
|
||||||
return render_template('assessment_form.html', form=form, title='Modifier l\'évaluation', assessment=assessment)
|
'id': exercise.id,
|
||||||
|
'title': exercise.title,
|
||||||
|
'description': exercise.description or '',
|
||||||
|
'order': exercise.order,
|
||||||
|
'grading_elements': []
|
||||||
|
}
|
||||||
|
for element in exercise.grading_elements:
|
||||||
|
element_data = {
|
||||||
|
'id': element.id,
|
||||||
|
'label': element.label,
|
||||||
|
'description': element.description or '',
|
||||||
|
'skill': element.skill or '',
|
||||||
|
'max_points': float(element.max_points),
|
||||||
|
'grading_type': element.grading_type
|
||||||
|
}
|
||||||
|
exercise_data['grading_elements'].append(element_data)
|
||||||
|
exercises_data.append(exercise_data)
|
||||||
|
|
||||||
|
return render_template('assessment_form_unified.html',
|
||||||
|
form=form,
|
||||||
|
title='Modifier l\'évaluation complète',
|
||||||
|
assessment=assessment,
|
||||||
|
exercises_json=exercises_data,
|
||||||
|
is_edit=True)
|
||||||
|
|
||||||
|
@bp.route('/new', methods=['GET', 'POST'])
|
||||||
|
@handle_db_errors
|
||||||
|
def new():
|
||||||
|
form = AssessmentForm()
|
||||||
|
|
||||||
|
result = _handle_unified_assessment_request(form, is_edit=False)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
|
||||||
|
return render_template('assessment_form_unified.html', form=form, title='Nouvelle évaluation complète')
|
||||||
|
|
||||||
@bp.route('/<int:id>/delete', methods=['POST'])
|
@bp.route('/<int:id>/delete', methods=['POST'])
|
||||||
|
@handle_db_errors
|
||||||
def delete(id):
|
def delete(id):
|
||||||
assessment = Assessment.query.get_or_404(id)
|
assessment = Assessment.query.get_or_404(id)
|
||||||
|
title = assessment.title # Conserver pour le log
|
||||||
db.session.delete(assessment)
|
db.session.delete(assessment)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
current_app.logger.info(f'Évaluation supprimée: {title} (ID: {id})')
|
||||||
flash('Évaluation supprimée avec succès !', 'success')
|
flash('Évaluation supprimée avec succès !', 'success')
|
||||||
return redirect(url_for('assessments.list'))
|
return redirect(url_for('assessments.list'))
|
||||||
@@ -1,113 +1,17 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash, request
|
from flask import Blueprint, render_template
|
||||||
from models import db, Assessment, Exercise, GradingElement
|
from models import Assessment, Exercise
|
||||||
from forms import ExerciseForm, GradingElementForm
|
from utils import handle_db_errors
|
||||||
|
|
||||||
bp = Blueprint('exercises', __name__)
|
bp = Blueprint('exercises', __name__)
|
||||||
|
|
||||||
# Exercise routes
|
# Routes de consultation seulement - La création/modification se fait via le formulaire unifié d'évaluation
|
||||||
@bp.route('/assessments/<int:assessment_id>/exercises/new', methods=['GET', 'POST'])
|
|
||||||
def new(assessment_id):
|
|
||||||
assessment = Assessment.query.get_or_404(assessment_id)
|
|
||||||
form = ExerciseForm()
|
|
||||||
|
|
||||||
# Set default order to next available
|
|
||||||
if form.order.data == 1: # Only if it's the default value
|
|
||||||
max_order = db.session.query(db.func.max(Exercise.order)).filter_by(assessment_id=assessment_id).scalar()
|
|
||||||
form.order.data = (max_order or 0) + 1
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
exercise = Exercise(
|
|
||||||
assessment_id=assessment_id,
|
|
||||||
title=form.title.data,
|
|
||||||
description=form.description.data,
|
|
||||||
order=form.order.data
|
|
||||||
)
|
|
||||||
db.session.add(exercise)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Exercice créé avec succès !', 'success')
|
|
||||||
return redirect(url_for('exercises.detail', assessment_id=assessment_id, id=exercise.id))
|
|
||||||
|
|
||||||
return render_template('exercise_form.html', form=form, assessment=assessment, title='Nouvel exercice')
|
|
||||||
|
|
||||||
@bp.route('/assessments/<int:assessment_id>/exercises/<int:id>')
|
@bp.route('/assessments/<int:assessment_id>/exercises/<int:id>')
|
||||||
|
@handle_db_errors
|
||||||
def detail(assessment_id, id):
|
def detail(assessment_id, id):
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
assessment = Assessment.query.get_or_404(assessment_id)
|
assessment = Assessment.query.get_or_404(assessment_id)
|
||||||
exercise = Exercise.query.filter_by(id=id, assessment_id=assessment_id).first_or_404()
|
exercise = Exercise.query.options(
|
||||||
return render_template('exercise_detail.html', assessment=assessment, exercise=exercise)
|
joinedload(Exercise.grading_elements)
|
||||||
|
).filter_by(id=id, assessment_id=assessment_id).first_or_404()
|
||||||
@bp.route('/assessments/<int:assessment_id>/exercises/<int:id>/edit', methods=['GET', 'POST'])
|
return render_template('exercise_detail.html', assessment=assessment, exercise=exercise)
|
||||||
def edit(assessment_id, id):
|
|
||||||
assessment = Assessment.query.get_or_404(assessment_id)
|
|
||||||
exercise = Exercise.query.filter_by(id=id, assessment_id=assessment_id).first_or_404()
|
|
||||||
form = ExerciseForm(obj=exercise)
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
exercise.title = form.title.data
|
|
||||||
exercise.description = form.description.data
|
|
||||||
exercise.order = form.order.data
|
|
||||||
db.session.commit()
|
|
||||||
flash('Exercice modifié avec succès !', 'success')
|
|
||||||
return redirect(url_for('exercises.detail', assessment_id=assessment_id, id=exercise.id))
|
|
||||||
|
|
||||||
return render_template('exercise_form.html', form=form, assessment=assessment, exercise=exercise, title='Modifier l\'exercice')
|
|
||||||
|
|
||||||
@bp.route('/assessments/<int:assessment_id>/exercises/<int:id>/delete', methods=['POST'])
|
|
||||||
def delete(assessment_id, id):
|
|
||||||
assessment = Assessment.query.get_or_404(assessment_id)
|
|
||||||
exercise = Exercise.query.filter_by(id=id, assessment_id=assessment_id).first_or_404()
|
|
||||||
db.session.delete(exercise)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Exercice supprimé avec succès !', 'success')
|
|
||||||
return redirect(url_for('assessments.detail', id=assessment_id))
|
|
||||||
|
|
||||||
# GradingElement routes
|
|
||||||
@bp.route('/assessments/<int:assessment_id>/exercises/<int:exercise_id>/elements/new', methods=['GET', 'POST'])
|
|
||||||
def new_grading_element(assessment_id, exercise_id):
|
|
||||||
assessment = Assessment.query.get_or_404(assessment_id)
|
|
||||||
exercise = Exercise.query.filter_by(id=exercise_id, assessment_id=assessment_id).first_or_404()
|
|
||||||
form = GradingElementForm()
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
element = GradingElement(
|
|
||||||
exercise_id=exercise_id,
|
|
||||||
label=form.label.data,
|
|
||||||
description=form.description.data,
|
|
||||||
skill=form.skill.data,
|
|
||||||
max_points=form.max_points.data,
|
|
||||||
grading_type=form.grading_type.data
|
|
||||||
)
|
|
||||||
db.session.add(element)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Élément de notation créé avec succès !', 'success')
|
|
||||||
return redirect(url_for('exercises.detail', assessment_id=assessment_id, id=exercise_id))
|
|
||||||
|
|
||||||
return render_template('grading_element_form.html', form=form, assessment=assessment, exercise=exercise, title='Nouvel élément de notation')
|
|
||||||
|
|
||||||
@bp.route('/assessments/<int:assessment_id>/exercises/<int:exercise_id>/elements/<int:id>/edit', methods=['GET', 'POST'])
|
|
||||||
def edit_grading_element(assessment_id, exercise_id, id):
|
|
||||||
assessment = Assessment.query.get_or_404(assessment_id)
|
|
||||||
exercise = Exercise.query.filter_by(id=exercise_id, assessment_id=assessment_id).first_or_404()
|
|
||||||
element = GradingElement.query.filter_by(id=id, exercise_id=exercise_id).first_or_404()
|
|
||||||
form = GradingElementForm(obj=element)
|
|
||||||
|
|
||||||
if form.validate_on_submit():
|
|
||||||
element.label = form.label.data
|
|
||||||
element.description = form.description.data
|
|
||||||
element.skill = form.skill.data
|
|
||||||
element.max_points = form.max_points.data
|
|
||||||
element.grading_type = form.grading_type.data
|
|
||||||
db.session.commit()
|
|
||||||
flash('Élément de notation modifié avec succès !', 'success')
|
|
||||||
return redirect(url_for('exercises.detail', assessment_id=assessment_id, id=exercise_id))
|
|
||||||
|
|
||||||
return render_template('grading_element_form.html', form=form, assessment=assessment, exercise=exercise, element=element, title='Modifier l\'élément de notation')
|
|
||||||
|
|
||||||
@bp.route('/assessments/<int:assessment_id>/exercises/<int:exercise_id>/elements/<int:id>/delete', methods=['POST'])
|
|
||||||
def delete_grading_element(assessment_id, exercise_id, id):
|
|
||||||
assessment = Assessment.query.get_or_404(assessment_id)
|
|
||||||
exercise = Exercise.query.filter_by(id=exercise_id, assessment_id=assessment_id).first_or_404()
|
|
||||||
element = GradingElement.query.filter_by(id=id, exercise_id=exercise_id).first_or_404()
|
|
||||||
db.session.delete(element)
|
|
||||||
db.session.commit()
|
|
||||||
flash('Élément de notation supprimé avec succès !', 'success')
|
|
||||||
return redirect(url_for('exercises.detail', assessment_id=assessment_id, id=exercise_id))
|
|
||||||
140
services.py
Normal file
140
services.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from models import db, Assessment, Exercise, GradingElement
|
||||||
|
from utils import safe_int_conversion, safe_decimal_conversion, validate_json_data, ValidationError, log_user_action
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
class AssessmentService:
|
||||||
|
"""Service pour gérer les opérations sur les évaluations"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_assessment(form_data):
|
||||||
|
"""Crée une nouvelle évaluation"""
|
||||||
|
assessment = Assessment(
|
||||||
|
title=form_data['title'],
|
||||||
|
description=form_data.get('description', ''),
|
||||||
|
date=form_data['date'],
|
||||||
|
class_group_id=form_data['class_group_id'],
|
||||||
|
coefficient=form_data['coefficient']
|
||||||
|
)
|
||||||
|
db.session.add(assessment)
|
||||||
|
db.session.flush() # Pour obtenir l'ID
|
||||||
|
log_user_action("Création évaluation", f"ID: {assessment.id}, Titre: {assessment.title}")
|
||||||
|
return assessment
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def update_assessment_basic_info(assessment, form_data):
|
||||||
|
"""Met à jour les informations de base d'une évaluation"""
|
||||||
|
assessment.title = form_data['title']
|
||||||
|
assessment.description = form_data.get('description', '')
|
||||||
|
assessment.date = form_data['date']
|
||||||
|
assessment.class_group_id = form_data['class_group_id']
|
||||||
|
assessment.coefficient = form_data['coefficient']
|
||||||
|
log_user_action("Modification évaluation", f"ID: {assessment.id}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_assessment_exercises(assessment):
|
||||||
|
"""Supprime tous les exercices d'une évaluation (pour remplacement)"""
|
||||||
|
for exercise in assessment.exercises:
|
||||||
|
db.session.delete(exercise)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_assessment_with_exercises(assessment_data, is_edit=False, existing_assessment=None):
|
||||||
|
"""Traite une évaluation complète avec exercices et éléments"""
|
||||||
|
# Validation des données de base
|
||||||
|
required_fields = ['title', 'date', 'class_group_id', 'coefficient']
|
||||||
|
validate_json_data(assessment_data, required_fields)
|
||||||
|
|
||||||
|
# Validation et conversion des types
|
||||||
|
try:
|
||||||
|
class_group_id = safe_int_conversion(assessment_data['class_group_id'], "ID de classe")
|
||||||
|
coefficient = safe_decimal_conversion(assessment_data['coefficient'], "coefficient")
|
||||||
|
|
||||||
|
# Validation de la date
|
||||||
|
date_str = assessment_data.get('date')
|
||||||
|
if date_str:
|
||||||
|
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
else:
|
||||||
|
raise ValidationError("Date requise")
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
# Préparation des données validées
|
||||||
|
validated_data = {
|
||||||
|
'title': assessment_data['title'].strip(),
|
||||||
|
'description': assessment_data.get('description', '').strip(),
|
||||||
|
'date': date_obj,
|
||||||
|
'class_group_id': class_group_id,
|
||||||
|
'coefficient': coefficient
|
||||||
|
}
|
||||||
|
|
||||||
|
# Création ou mise à jour de l'évaluation
|
||||||
|
if is_edit and existing_assessment:
|
||||||
|
AssessmentService.update_assessment_basic_info(existing_assessment, validated_data)
|
||||||
|
AssessmentService.delete_assessment_exercises(existing_assessment)
|
||||||
|
assessment = existing_assessment
|
||||||
|
else:
|
||||||
|
assessment = AssessmentService.create_assessment(validated_data)
|
||||||
|
|
||||||
|
# Traitement des exercices
|
||||||
|
exercises_data = assessment_data.get('exercises', [])
|
||||||
|
AssessmentService.process_exercises(assessment, exercises_data)
|
||||||
|
|
||||||
|
return assessment
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_exercises(assessment, exercises_data):
|
||||||
|
"""Traite les exercices d'une évaluation"""
|
||||||
|
for ex_data in exercises_data:
|
||||||
|
# Validation des données d'exercice
|
||||||
|
if not ex_data.get('title'):
|
||||||
|
raise ValidationError("Titre d'exercice requis")
|
||||||
|
|
||||||
|
try:
|
||||||
|
order = safe_int_conversion(ex_data.get('order', 1), "ordre de l'exercice")
|
||||||
|
if order < 1:
|
||||||
|
raise ValidationError("L'ordre de l'exercice doit être positif")
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
exercise = Exercise(
|
||||||
|
title=ex_data['title'].strip(),
|
||||||
|
description=ex_data.get('description', '').strip(),
|
||||||
|
order=order,
|
||||||
|
assessment_id=assessment.id
|
||||||
|
)
|
||||||
|
db.session.add(exercise)
|
||||||
|
db.session.flush()
|
||||||
|
|
||||||
|
# Traitement des éléments de notation
|
||||||
|
grading_elements_data = ex_data.get('grading_elements', [])
|
||||||
|
AssessmentService.process_grading_elements(exercise, grading_elements_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_grading_elements(exercise, grading_elements_data):
|
||||||
|
"""Traite les éléments de notation d'un exercice"""
|
||||||
|
for elem_data in grading_elements_data:
|
||||||
|
# Validation des données d'élément
|
||||||
|
if not elem_data.get('label'):
|
||||||
|
raise ValidationError("Libellé d'élément de notation requis")
|
||||||
|
|
||||||
|
grading_type = elem_data.get('grading_type', 'points')
|
||||||
|
if grading_type not in ['points', 'score']:
|
||||||
|
raise ValidationError("Type de notation invalide")
|
||||||
|
|
||||||
|
try:
|
||||||
|
max_points = safe_decimal_conversion(elem_data.get('max_points'), "points maximum")
|
||||||
|
if max_points <= 0:
|
||||||
|
raise ValidationError("Les points maximum doivent être positifs")
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
grading_element = GradingElement(
|
||||||
|
label=elem_data['label'].strip(),
|
||||||
|
description=elem_data.get('description', '').strip(),
|
||||||
|
skill=elem_data.get('skill', '').strip(),
|
||||||
|
max_points=max_points,
|
||||||
|
grading_type=grading_type,
|
||||||
|
exercise_id=exercise.id
|
||||||
|
)
|
||||||
|
db.session.add(grading_element)
|
||||||
140
static/css/app.css
Normal file
140
static/css/app.css
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/* Styles personnalisés pour l'application */
|
||||||
|
|
||||||
|
/* Focus visible amélioré pour l'accessibilité */
|
||||||
|
*:focus {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Amélioration du contraste pour les textes */
|
||||||
|
.text-gray-600 {
|
||||||
|
color: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-gray-700 {
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transitions fluides */
|
||||||
|
.transition-colors {
|
||||||
|
transition-property: color, background-color, border-color;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transition-duration: 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip link pour l'accessibilité */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 6px;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Améliorations pour les formulaires */
|
||||||
|
input:invalid,
|
||||||
|
select:invalid,
|
||||||
|
textarea:invalid {
|
||||||
|
border-color: #ef4444;
|
||||||
|
box-shadow: 0 0 0 1px #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:valid,
|
||||||
|
select:valid,
|
||||||
|
textarea:valid {
|
||||||
|
border-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Amélioration des messages d'erreur */
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message::before {
|
||||||
|
content: "⚠";
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Styles pour les boutons avec meilleur contraste */
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #2563eb;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 2px solid #2563eb;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:focus {
|
||||||
|
outline: 2px solid #3b82f6;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
color: #374151;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 2px solid #d1d5db;
|
||||||
|
transition: all 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
border-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables responsive */
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.table-responsive table {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation pour les messages flash */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-message {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Améliorations pour mobile */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.mobile-menu-button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-menu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High contrast mode support */
|
||||||
|
@media (prefers-contrast: high) {
|
||||||
|
.bg-gray-100 { background-color: #ffffff; }
|
||||||
|
.text-gray-600 { color: #000000; }
|
||||||
|
.border-gray-300 { border-color: #000000; }
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
<p class="text-gray-600">{{ assessment.class_group.name }} - {{ assessment.date.strftime('%d/%m/%Y') }}</p>
|
<p class="text-gray-600">{{ assessment.class_group.name }} - {{ assessment.date.strftime('%d/%m/%Y') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<a href="{{ url_for('assessments.edit', id=assessment.id) }}" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
<a href="{{ url_for('assessments.edit', id=assessment.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
Modifier
|
Modifier l'évaluation complète
|
||||||
</a>
|
</a>
|
||||||
<button onclick="if(confirm('Êtes-vous sûr de vouloir supprimer cette évaluation ?')) { document.getElementById('delete-form').submit(); }"
|
<button onclick="if(confirm('Êtes-vous sûr de vouloir supprimer cette évaluation ?')) { document.getElementById('delete-form').submit(); }"
|
||||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
@@ -62,9 +62,9 @@
|
|||||||
<div class="bg-white shadow rounded-lg">
|
<div class="bg-white shadow rounded-lg">
|
||||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Exercices</h2>
|
<h2 class="text-lg font-medium text-gray-900">Exercices</h2>
|
||||||
<a href="{{ url_for('exercises.new', assessment_id=assessment.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
<span class="text-sm text-gray-500">
|
||||||
Ajouter un exercice
|
Utilisez "Modifier l'évaluation complète" pour ajouter des exercices
|
||||||
</a>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
{% if assessment.exercises %}
|
{% if assessment.exercises %}
|
||||||
@@ -85,9 +85,6 @@
|
|||||||
<a href="{{ url_for('exercises.detail', assessment_id=assessment.id, id=exercise.id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
<a href="{{ url_for('exercises.detail', assessment_id=assessment.id, id=exercise.id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
||||||
Voir détails
|
Voir détails
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('exercises.edit', assessment_id=assessment.id, id=exercise.id) }}" class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
|
||||||
Modifier
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,8 +98,8 @@
|
|||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Aucun exercice</h3>
|
<h3 class="mt-2 text-sm font-medium text-gray-900">Aucun exercice</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">Commencez par ajouter le premier exercice de cette évaluation.</p>
|
<p class="mt-1 text-sm text-gray-500">Commencez par ajouter le premier exercice de cette évaluation.</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<a href="{{ url_for('exercises.new', assessment_id=assessment.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
<a href="{{ url_for('assessments.edit', id=assessment.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
Ajouter un exercice
|
Modifier l'évaluation complète
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ title }} - Gestion Scolaire{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<div class="mb-6">
|
|
||||||
<a href="{{ url_for('assessments.list') }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
|
||||||
← Retour aux évaluations
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white shadow rounded-lg">
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h1 class="text-xl font-semibold text-gray-900">{{ title }}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="POST" class="px-6 py-4 space-y-6">
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.title.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.title.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.title(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
|
||||||
{% if form.title.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.title.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.description.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.description.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.description(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500", rows="3") }}
|
|
||||||
{% if form.description.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.description.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.date.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.date.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.date(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
|
||||||
{% if form.date.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.date.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.coefficient.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.coefficient.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.coefficient(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500", step="0.1") }}
|
|
||||||
{% if form.coefficient.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.coefficient.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.class_group_id.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.class_group_id.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.class_group_id(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
|
||||||
{% if form.class_group_id.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.class_group_id.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
|
||||||
<a href="{{ url_for('assessments.list') }}" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
|
||||||
Annuler
|
|
||||||
</a>
|
|
||||||
{{ form.submit(class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors") }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
532
templates/assessment_form_unified.html
Normal file
532
templates/assessment_form_unified.html
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }} - Gestion Scolaire{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
{% if is_edit %}
|
||||||
|
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
||||||
|
← Retour à l'évaluation
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('assessments.list') }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
||||||
|
← Retour aux évaluations
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">{{ title }}</h1>
|
||||||
|
{% if is_edit %}
|
||||||
|
<p class="text-sm text-gray-600 mt-1">Modifiez votre évaluation complète avec exercices et éléments de notation</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-gray-600 mt-1">Créez votre évaluation complète avec exercices et éléments de notation</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="unified-form" method="POST" class="px-6 py-6 space-y-8">
|
||||||
|
{{ form.hidden_tag() }}
|
||||||
|
|
||||||
|
<!-- Section Évaluation -->
|
||||||
|
<div class="bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||||
|
<h2 class="text-lg font-medium text-blue-900 mb-4">📝 Informations de l'évaluation</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label for="{{ form.title.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{{ form.title.label.text }}
|
||||||
|
</label>
|
||||||
|
{{ form.title(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
||||||
|
{% if form.title.errors %}
|
||||||
|
<div class="mt-1 text-sm text-red-600">
|
||||||
|
{% for error in form.title.errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="{{ form.class_group_id.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{{ form.class_group_id.label.text }}
|
||||||
|
</label>
|
||||||
|
{{ form.class_group_id(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
||||||
|
{% if form.class_group_id.errors %}
|
||||||
|
<div class="mt-1 text-sm text-red-600">
|
||||||
|
{% for error in form.class_group_id.errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="{{ form.date.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{{ form.date.label.text }}
|
||||||
|
</label>
|
||||||
|
{{ form.date(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
||||||
|
{% if form.date.errors %}
|
||||||
|
<div class="mt-1 text-sm text-red-600">
|
||||||
|
{% for error in form.date.errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="{{ form.coefficient.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{{ form.coefficient.label.text }}
|
||||||
|
</label>
|
||||||
|
{{ form.coefficient(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500", step="0.5") }}
|
||||||
|
{% if form.coefficient.errors %}
|
||||||
|
<div class="mt-1 text-sm text-red-600">
|
||||||
|
{% for error in form.coefficient.errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label for="{{ form.description.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{{ form.description.label.text }}
|
||||||
|
</label>
|
||||||
|
{{ form.description(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500", rows="3") }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="mt-1 text-sm text-red-600">
|
||||||
|
{% for error in form.description.errors %}
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Exercices -->
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h2 class="text-lg font-medium text-green-900">🏃 Exercices</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="exercises-container" class="space-y-4">
|
||||||
|
<!-- Les exercices seront ajoutés ici dynamiquement -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-exercises" class="text-center py-8 text-green-700">
|
||||||
|
<p class="text-sm">Aucun exercice ajouté. Utilisez le bouton ci-dessous pour commencer.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton d'ajout placé après la liste pour une meilleure navigation clavier -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-green-200">
|
||||||
|
<button type="button" id="add-exercise" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
|
||||||
|
➕ Ajouter un exercice
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Guide d'aide -->
|
||||||
|
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-medium text-gray-900 mb-2">💡 Guide rapide</h3>
|
||||||
|
<div class="text-xs text-gray-700 space-y-1">
|
||||||
|
<p><strong>Points :</strong> Notation classique (ex: 2.5/4 points)</p>
|
||||||
|
<p><strong>Score :</strong> Évaluation par niveaux (0=non acquis, 1=en cours, 2=acquis, 3=expert, .=non évalué)</p>
|
||||||
|
<p><strong>Ordre :</strong> Les exercices et éléments seront affichés dans l'ordre numérique</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
||||||
|
{% if is_edit %}
|
||||||
|
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="px-6 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2">
|
||||||
|
Annuler
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||||
|
💾 Modifier l'évaluation complète
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('assessments.list') }}" class="px-6 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2">
|
||||||
|
Annuler
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="px-6 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||||
|
💾 Créer l'évaluation complète
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Template pour un exercice -->
|
||||||
|
<template id="exercise-template">
|
||||||
|
<div class="exercise-item border border-green-300 rounded-lg p-4 bg-white">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h3 class="text-md font-medium text-green-800">Exercice <span class="exercise-number"></span></h3>
|
||||||
|
<button type="button" class="remove-exercise text-red-600 hover:text-red-800 text-sm font-medium">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Titre de l'exercice</label>
|
||||||
|
<input type="text" class="exercise-title block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-green-500 focus:border-green-500" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Ordre</label>
|
||||||
|
<input type="number" class="exercise-order block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-green-500 focus:border-green-500" min="1" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Description (optionnel)</label>
|
||||||
|
<textarea class="exercise-description block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-green-500 focus:border-green-500" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Éléments de notation -->
|
||||||
|
<div class="bg-purple-50 border border-purple-200 rounded p-4">
|
||||||
|
<div class="mb-3">
|
||||||
|
<h4 class="text-sm font-medium text-purple-900">Éléments de notation</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grading-elements-container space-y-3">
|
||||||
|
<!-- Les éléments de notation seront ajoutés ici -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="no-grading-elements text-center py-4 text-purple-700 text-xs">
|
||||||
|
Aucun élément de notation. Utilisez le bouton ci-dessous pour commencer.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bouton d'ajout placé après la liste pour une meilleure navigation clavier -->
|
||||||
|
<div class="mt-3 pt-3 border-t border-purple-200">
|
||||||
|
<button type="button" class="add-grading-element bg-purple-600 hover:bg-purple-700 text-white px-3 py-1 rounded text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-1">
|
||||||
|
➕ Ajouter élément
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Template pour un élément de notation -->
|
||||||
|
<template id="grading-element-template">
|
||||||
|
<div class="grading-element-item border border-purple-300 rounded p-3 bg-white">
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<h5 class="text-sm font-medium text-purple-800">Élément de notation</h5>
|
||||||
|
<button type="button" class="remove-grading-element text-red-600 hover:text-red-800 text-xs font-medium">
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Label</label>
|
||||||
|
<input type="text" class="element-label block w-full border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-purple-500 focus:border-purple-500" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Compétence</label>
|
||||||
|
<input type="text" class="element-skill block w-full border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-purple-500 focus:border-purple-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Points max</label>
|
||||||
|
<input type="number" step="0.1" min="0" class="element-max-points block w-full border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-purple-500 focus:border-purple-500" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Type de notation</label>
|
||||||
|
<select class="element-grading-type block w-full border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-purple-500 focus:border-purple-500" required>
|
||||||
|
<option value="">Choisir...</option>
|
||||||
|
<option value="points">Points</option>
|
||||||
|
<option value="score">Score</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Description (optionnel)</label>
|
||||||
|
<textarea class="element-description block w-full border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-purple-500 focus:border-purple-500" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let exerciseCounter = 0;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const addExerciseBtn = document.getElementById('add-exercise');
|
||||||
|
const exercisesContainer = document.getElementById('exercises-container');
|
||||||
|
const noExercisesMsg = document.getElementById('no-exercises');
|
||||||
|
const form = document.getElementById('unified-form');
|
||||||
|
|
||||||
|
// Pré-remplir les données en mode édition
|
||||||
|
{% if is_edit %}
|
||||||
|
loadExistingData();
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// Ajouter un exercice
|
||||||
|
addExerciseBtn.addEventListener('click', function() {
|
||||||
|
addExercise();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Soumission du formulaire
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
submitForm();
|
||||||
|
});
|
||||||
|
|
||||||
|
function addExercise() {
|
||||||
|
exerciseCounter++;
|
||||||
|
const template = document.getElementById('exercise-template');
|
||||||
|
const exerciseDiv = template.content.cloneNode(true);
|
||||||
|
|
||||||
|
// Mettre à jour le numéro d'exercice
|
||||||
|
exerciseDiv.querySelector('.exercise-number').textContent = exerciseCounter;
|
||||||
|
exerciseDiv.querySelector('.exercise-order').value = exerciseCounter;
|
||||||
|
|
||||||
|
// Ajouter les event listeners
|
||||||
|
const removeBtn = exerciseDiv.querySelector('.remove-exercise');
|
||||||
|
removeBtn.addEventListener('click', function() {
|
||||||
|
removeExercise(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addElementBtn = exerciseDiv.querySelector('.add-grading-element');
|
||||||
|
addElementBtn.addEventListener('click', function() {
|
||||||
|
addGradingElement(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
exercisesContainer.appendChild(exerciseDiv);
|
||||||
|
updateExercisesVisibility();
|
||||||
|
|
||||||
|
// Focus automatique sur le champ titre du nouvel exercice pour faciliter la navigation clavier
|
||||||
|
setTimeout(() => {
|
||||||
|
const titleInput = exercisesContainer.lastElementChild.querySelector('.exercise-title');
|
||||||
|
if (titleInput) {
|
||||||
|
titleInput.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExercise(btn) {
|
||||||
|
btn.closest('.exercise-item').remove();
|
||||||
|
updateExercisesVisibility();
|
||||||
|
renumberExercises();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGradingElement(btn) {
|
||||||
|
const exerciseDiv = btn.closest('.exercise-item');
|
||||||
|
const elementsContainer = exerciseDiv.querySelector('.grading-elements-container');
|
||||||
|
const noElementsMsg = exerciseDiv.querySelector('.no-grading-elements');
|
||||||
|
|
||||||
|
const template = document.getElementById('grading-element-template');
|
||||||
|
const elementDiv = template.content.cloneNode(true);
|
||||||
|
|
||||||
|
// Ajouter l'event listener pour supprimer
|
||||||
|
const removeBtn = elementDiv.querySelector('.remove-grading-element');
|
||||||
|
removeBtn.addEventListener('click', function() {
|
||||||
|
removeGradingElement(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
elementsContainer.appendChild(elementDiv);
|
||||||
|
noElementsMsg.style.display = 'none';
|
||||||
|
|
||||||
|
// Focus automatique sur le champ label du nouvel élément pour faciliter la navigation clavier
|
||||||
|
setTimeout(() => {
|
||||||
|
const labelInput = elementsContainer.lastElementChild.querySelector('.element-label');
|
||||||
|
if (labelInput) {
|
||||||
|
labelInput.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGradingElement(btn) {
|
||||||
|
const exerciseDiv = btn.closest('.exercise-item');
|
||||||
|
const elementsContainer = exerciseDiv.querySelector('.grading-elements-container');
|
||||||
|
const noElementsMsg = exerciseDiv.querySelector('.no-grading-elements');
|
||||||
|
|
||||||
|
btn.closest('.grading-element-item').remove();
|
||||||
|
|
||||||
|
if (elementsContainer.children.length === 0) {
|
||||||
|
noElementsMsg.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExercisesVisibility() {
|
||||||
|
const hasExercises = exercisesContainer.children.length > 0;
|
||||||
|
noExercisesMsg.style.display = hasExercises ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renumberExercises() {
|
||||||
|
const exercises = exercisesContainer.querySelectorAll('.exercise-item');
|
||||||
|
exercises.forEach((exercise, index) => {
|
||||||
|
const number = index + 1;
|
||||||
|
exercise.querySelector('.exercise-number').textContent = number;
|
||||||
|
exercise.querySelector('.exercise-order').value = number;
|
||||||
|
});
|
||||||
|
exerciseCounter = exercises.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFormData() {
|
||||||
|
const exercises = [];
|
||||||
|
const exerciseItems = exercisesContainer.querySelectorAll('.exercise-item');
|
||||||
|
|
||||||
|
exerciseItems.forEach(exerciseItem => {
|
||||||
|
const title = exerciseItem.querySelector('.exercise-title').value;
|
||||||
|
const description = exerciseItem.querySelector('.exercise-description').value;
|
||||||
|
const order = parseInt(exerciseItem.querySelector('.exercise-order').value);
|
||||||
|
|
||||||
|
if (!title.trim()) return;
|
||||||
|
|
||||||
|
const gradingElements = [];
|
||||||
|
const elementItems = exerciseItem.querySelectorAll('.grading-element-item');
|
||||||
|
|
||||||
|
elementItems.forEach(elementItem => {
|
||||||
|
const label = elementItem.querySelector('.element-label').value;
|
||||||
|
const skill = elementItem.querySelector('.element-skill').value;
|
||||||
|
const maxPoints = parseFloat(elementItem.querySelector('.element-max-points').value);
|
||||||
|
const gradingType = elementItem.querySelector('.element-grading-type').value;
|
||||||
|
const description = elementItem.querySelector('.element-description').value;
|
||||||
|
|
||||||
|
if (!label.trim() || !maxPoints || !gradingType) return;
|
||||||
|
|
||||||
|
gradingElements.push({
|
||||||
|
label: label.trim(),
|
||||||
|
skill: skill.trim(),
|
||||||
|
max_points: maxPoints,
|
||||||
|
grading_type: gradingType,
|
||||||
|
description: description.trim()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
exercises.push({
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
order: order,
|
||||||
|
grading_elements: gradingElements
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return exercises;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitForm() {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const exercises = collectFormData();
|
||||||
|
|
||||||
|
// Validation côté client
|
||||||
|
if (exercises.length === 0) {
|
||||||
|
alert('Vous devez ajouter au moins un exercice.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasElementsWithoutGrading = false;
|
||||||
|
exercises.forEach(ex => {
|
||||||
|
if (ex.grading_elements.length === 0) {
|
||||||
|
hasElementsWithoutGrading = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hasElementsWithoutGrading) {
|
||||||
|
if (!confirm('Certains exercices n\'ont pas d\'éléments de notation. Voulez-vous continuer ?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoyer via AJAX
|
||||||
|
const data = {
|
||||||
|
title: formData.get('title'),
|
||||||
|
description: formData.get('description'),
|
||||||
|
date: formData.get('date'),
|
||||||
|
class_group_id: formData.get('class_group_id'),
|
||||||
|
coefficient: formData.get('coefficient'),
|
||||||
|
exercises: exercises
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ajouter le CSRF token aux données
|
||||||
|
data.csrf_token = formData.get('csrf_token');
|
||||||
|
|
||||||
|
fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
window.location.href = `/assessments/${data.assessment_id}`;
|
||||||
|
} else {
|
||||||
|
alert('Erreur lors de la création : ' + JSON.stringify(data.errors));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Erreur:', error);
|
||||||
|
alert('Une erreur est survenue lors de la création de l\'évaluation.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if is_edit %}
|
||||||
|
function loadExistingData() {
|
||||||
|
// Charger les exercices existants
|
||||||
|
const existingExercises = {{ exercises_json|tojson if exercises_json else "[]" }};
|
||||||
|
|
||||||
|
existingExercises.forEach(exercise => {
|
||||||
|
exerciseCounter++;
|
||||||
|
const template = document.getElementById('exercise-template');
|
||||||
|
const exerciseDiv = template.content.cloneNode(true);
|
||||||
|
|
||||||
|
// Remplir les données de l'exercice
|
||||||
|
exerciseDiv.querySelector('.exercise-number').textContent = exerciseCounter;
|
||||||
|
exerciseDiv.querySelector('.exercise-title').value = exercise.title;
|
||||||
|
exerciseDiv.querySelector('.exercise-description').value = exercise.description || '';
|
||||||
|
exerciseDiv.querySelector('.exercise-order').value = exercise.order;
|
||||||
|
|
||||||
|
// Ajouter les event listeners
|
||||||
|
const removeBtn = exerciseDiv.querySelector('.remove-exercise');
|
||||||
|
removeBtn.addEventListener('click', function() {
|
||||||
|
removeExercise(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addElementBtn = exerciseDiv.querySelector('.add-grading-element');
|
||||||
|
addElementBtn.addEventListener('click', function() {
|
||||||
|
addGradingElement(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
const elementsContainer = exerciseDiv.querySelector('.grading-elements-container');
|
||||||
|
const noElementsMsg = exerciseDiv.querySelector('.no-grading-elements');
|
||||||
|
|
||||||
|
// Charger les éléments de notation existants
|
||||||
|
exercise.grading_elements.forEach(element => {
|
||||||
|
const elementTemplate = document.getElementById('grading-element-template');
|
||||||
|
const elementDiv = elementTemplate.content.cloneNode(true);
|
||||||
|
|
||||||
|
// Remplir les données de l'élément
|
||||||
|
elementDiv.querySelector('.element-label').value = element.label;
|
||||||
|
elementDiv.querySelector('.element-skill').value = element.skill || '';
|
||||||
|
elementDiv.querySelector('.element-max-points').value = element.max_points;
|
||||||
|
elementDiv.querySelector('.element-grading-type').value = element.grading_type;
|
||||||
|
elementDiv.querySelector('.element-description').value = element.description || '';
|
||||||
|
|
||||||
|
// Ajouter l'event listener pour supprimer
|
||||||
|
const removeElementBtn = elementDiv.querySelector('.remove-grading-element');
|
||||||
|
removeElementBtn.addEventListener('click', function() {
|
||||||
|
removeGradingElement(this);
|
||||||
|
});
|
||||||
|
|
||||||
|
elementsContainer.appendChild(elementDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exercise.grading_elements.length > 0) {
|
||||||
|
noElementsMsg.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
exercisesContainer.appendChild(exerciseDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateExercisesVisibility();
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -6,9 +6,11 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Gestion des évaluations</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Gestion des évaluations</h1>
|
||||||
<a href="{{ url_for('assessments.new') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors">
|
<div>
|
||||||
Nouvelle évaluation
|
<a href="{{ url_for('assessments.new') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||||
</a>
|
✨ Nouvelle évaluation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if assessments %}
|
{% if assessments %}
|
||||||
@@ -42,14 +44,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="text-indigo-600 hover:text-indigo-900 text-sm font-medium">
|
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="text-indigo-600 hover:text-indigo-900 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 rounded-sm px-1 py-1" aria-label="Voir détails de {{ assessment.title }}">
|
||||||
Voir détails
|
👁️ Voir détails
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('grading.assessment_grading', assessment_id=assessment.id) }}" class="text-green-600 hover:text-green-900 text-sm font-medium">
|
<a href="{{ url_for('grading.assessment_grading', assessment_id=assessment.id) }}" class="text-green-600 hover:text-green-900 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1 rounded-sm px-1 py-1" aria-label="Saisir notes pour {{ assessment.title }}">
|
||||||
Saisir notes
|
📝 Saisir notes
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('assessments.edit', id=assessment.id) }}" class="text-gray-600 hover:text-gray-900 text-sm font-medium">
|
<a href="{{ url_for('assessments.edit', id=assessment.id) }}" class="text-gray-600 hover:text-gray-900 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-1 rounded-sm px-1 py-1" aria-label="Modifier {{ assessment.title }}">
|
||||||
Modifier
|
✏️ Modifier
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,8 +68,8 @@
|
|||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Aucune évaluation</h3>
|
<h3 class="mt-2 text-sm font-medium text-gray-900">Aucune évaluation</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">Commencez par créer votre première évaluation.</p>
|
<p class="mt-1 text-sm text-gray-500">Commencez par créer votre première évaluation.</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<a href="{{ url_for('assessments.new') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors">
|
<a href="{{ url_for('assessments.new') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md transition-colors text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
|
||||||
Nouvelle évaluation
|
✨ Créer votre première évaluation
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,34 +5,66 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Gestion Scolaire{% endblock %}</title>
|
<title>{% block title %}Gestion Scolaire{% endblock %}</title>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
// Configuration Tailwind pour dark mode et focus
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 min-h-screen">
|
<body class="bg-gray-100 min-h-screen" role="document">
|
||||||
<nav class="bg-blue-600 text-white shadow-lg">
|
<nav class="bg-blue-600 text-white shadow-lg" role="navigation" aria-label="Navigation principale">
|
||||||
<div class="max-w-7xl mx-auto px-4">
|
<div class="max-w-7xl mx-auto px-4">
|
||||||
<div class="flex justify-between items-center py-4">
|
<div class="flex justify-between items-center py-4">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<h1 class="text-xl font-bold">
|
<h1 class="text-xl font-bold">
|
||||||
<a href="{{ url_for('index') }}" class="hover:text-blue-200">Gestion Scolaire</a>
|
<a href="{{ url_for('index') }}" class="hover:text-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-300 focus:ring-offset-2 focus:ring-offset-blue-600 rounded-sm" aria-label="Accueil - Gestion Scolaire">Gestion Scolaire</a>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-6">
|
<div class="flex space-x-6" role="menubar">
|
||||||
<a href="{{ url_for('index') }}" class="hover:text-blue-200 transition-colors">Accueil</a>
|
<a href="{{ url_for('index') }}" class="hover:text-blue-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 focus:ring-offset-2 focus:ring-offset-blue-600 rounded-sm px-2 py-1" role="menuitem">Accueil</a>
|
||||||
<a href="{{ url_for('classes') }}" class="hover:text-blue-200 transition-colors">Classes</a>
|
<a href="{{ url_for('classes') }}" class="hover:text-blue-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 focus:ring-offset-2 focus:ring-offset-blue-600 rounded-sm px-2 py-1" role="menuitem">Classes</a>
|
||||||
<a href="{{ url_for('students') }}" class="hover:text-blue-200 transition-colors">Élèves</a>
|
<a href="{{ url_for('students') }}" class="hover:text-blue-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 focus:ring-offset-2 focus:ring-offset-blue-600 rounded-sm px-2 py-1" role="menuitem">Élèves</a>
|
||||||
<a href="{{ url_for('assessments.list') }}" class="hover:text-blue-200 transition-colors">Évaluations</a>
|
<a href="{{ url_for('assessments.list') }}" class="hover:text-blue-200 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-300 focus:ring-offset-2 focus:ring-offset-blue-600 rounded-sm px-2 py-1" role="menuitem">Évaluations</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="max-w-7xl mx-auto px-4 py-8">
|
<main class="max-w-7xl mx-auto px-4 py-8" role="main">
|
||||||
|
<!-- Messages flash améliorés pour l'accessibilité -->
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
<div role="alert" aria-live="polite">
|
||||||
<div class="mb-4 p-4 rounded-md {% if category == 'error' %}bg-red-100 text-red-700 border border-red-300{% else %}bg-green-100 text-green-700 border border-green-300{% endif %}">
|
{% for category, message in messages %}
|
||||||
{{ message }}
|
<div class="mb-4 p-4 rounded-md {% if category == 'error' %}bg-red-100 text-red-700 border border-red-300{% else %}bg-green-100 text-green-700 border border-green-300{% endif %}" role="alert">
|
||||||
</div>
|
<div class="flex items-center">
|
||||||
{% endfor %}
|
{% if category == 'error' %}
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Erreur:</span>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="sr-only">Succès:</span>
|
||||||
|
{% endif %}
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
|||||||
20
templates/error.html
Normal file
20
templates/error.html
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Erreur - Gestion Scolaire{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto mt-16">
|
||||||
|
<div class="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||||
|
<div class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4">
|
||||||
|
<svg class="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-lg font-medium text-gray-900 mb-2">Une erreur s'est produite</h1>
|
||||||
|
<p class="text-sm text-gray-600 mb-4">{{ error or "Une erreur inattendue s'est produite." }}</p>
|
||||||
|
<a href="{{ url_for('index') }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Retour à l'accueil
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -13,18 +13,12 @@
|
|||||||
<p class="text-gray-600">{{ assessment.title }} - {{ assessment.class_group.name }}</p>
|
<p class="text-gray-600">{{ assessment.title }} - {{ assessment.class_group.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<a href="{{ url_for('exercises.edit', assessment_id=assessment.id, id=exercise.id) }}" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
<a href="{{ url_for('assessments.edit', id=assessment.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
Modifier
|
Modifier l'évaluation complète
|
||||||
</a>
|
</a>
|
||||||
<button onclick="if(confirm('Êtes-vous sûr de vouloir supprimer cet exercice ?')) { document.getElementById('delete-form').submit(); }"
|
|
||||||
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="delete-form" method="POST" action="{{ url_for('exercises.delete', assessment_id=assessment.id, id=exercise.id) }}" style="display: none;"></form>
|
|
||||||
|
|
||||||
<!-- Informations de l'exercice -->
|
<!-- Informations de l'exercice -->
|
||||||
<div class="bg-white shadow rounded-lg">
|
<div class="bg-white shadow rounded-lg">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
@@ -54,8 +48,8 @@
|
|||||||
<div class="bg-white shadow rounded-lg">
|
<div class="bg-white shadow rounded-lg">
|
||||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Éléments de notation</h2>
|
<h2 class="text-lg font-medium text-gray-900">Éléments de notation</h2>
|
||||||
<a href="{{ url_for('exercises.new_grading_element', assessment_id=assessment.id, exercise_id=exercise.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
<a href="{{ url_for('assessments.edit', id=assessment.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
Ajouter un élément
|
Modifier l'évaluation complète
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-6 py-4">
|
<div class="px-6 py-4">
|
||||||
@@ -83,13 +77,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex space-x-2 ml-4">
|
<div class="flex space-x-2 ml-4">
|
||||||
<a href="{{ url_for('exercises.edit_grading_element', assessment_id=assessment.id, exercise_id=exercise.id, id=element.id) }}" class="text-gray-600 hover:text-gray-800 text-sm font-medium">
|
<span class="text-xs text-gray-500">
|
||||||
Modifier
|
Utilisez "Modifier l'évaluation complète" pour modifier
|
||||||
</a>
|
</span>
|
||||||
<button onclick="if(confirm('Êtes-vous sûr de vouloir supprimer cet élément ?')) { document.getElementById('delete-element-{{ element.id }}').submit(); }" class="text-red-600 hover:text-red-800 text-sm font-medium">
|
|
||||||
Supprimer
|
|
||||||
</button>
|
|
||||||
<form id="delete-element-{{ element.id }}" method="POST" action="{{ url_for('exercises.delete_grading_element', assessment_id=assessment.id, exercise_id=exercise.id, id=element.id) }}" style="display: none;"></form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,8 +93,8 @@
|
|||||||
<h3 class="mt-2 text-sm font-medium text-gray-900">Aucun élément de notation</h3>
|
<h3 class="mt-2 text-sm font-medium text-gray-900">Aucun élément de notation</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">Commencez par ajouter le premier élément de notation pour cet exercice.</p>
|
<p class="mt-1 text-sm text-gray-500">Commencez par ajouter le premier élément de notation pour cet exercice.</p>
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
<a href="{{ url_for('exercises.new_grading_element', assessment_id=assessment.id, exercise_id=exercise.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
<a href="{{ url_for('assessments.edit', id=assessment.id) }}" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors">
|
||||||
Ajouter un élément
|
Modifier l'évaluation complète
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ title }} - Gestion Scolaire{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<div class="mb-6">
|
|
||||||
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
|
||||||
← Retour à l'évaluation "{{ assessment.title }}"
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white shadow rounded-lg">
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h1 class="text-xl font-semibold text-gray-900">{{ title }}</h1>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">Évaluation : {{ assessment.title }} ({{ assessment.class_group.name }})</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="POST" class="px-6 py-4 space-y-6">
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.title.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.title.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.title(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
|
||||||
{% if form.title.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.title.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.description.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.description.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.description(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500", rows="3") }}
|
|
||||||
{% if form.description.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.description.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.order.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.order.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.order(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
|
||||||
{% if form.order.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.order.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<p class="mt-1 text-xs text-gray-500">L'ordre détermine l'affichage des exercices dans l'évaluation</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
|
||||||
<a href="{{ url_for('assessments.detail', id=assessment.id) }}" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
|
||||||
Annuler
|
|
||||||
</a>
|
|
||||||
{{ form.submit(class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors") }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ title }} - Gestion Scolaire{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="max-w-2xl mx-auto">
|
|
||||||
<div class="mb-6">
|
|
||||||
<a href="{{ url_for('exercises.detail', assessment_id=assessment.id, id=exercise.id) }}" class="text-blue-600 hover:text-blue-800 text-sm font-medium">
|
|
||||||
← Retour à l'exercice "{{ exercise.title }}"
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white shadow rounded-lg">
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h1 class="text-xl font-semibold text-gray-900">{{ title }}</h1>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">{{ assessment.title }} > {{ exercise.title }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="POST" class="px-6 py-4 space-y-6">
|
|
||||||
{{ form.hidden_tag() }}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.label.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.label.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.label(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
|
||||||
{% if form.label.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.label.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<p class="mt-1 text-xs text-gray-500">Ex: "Calcul de base", "Méthode", "Présentation"</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.description.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.description.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.description(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500", rows="3") }}
|
|
||||||
{% if form.description.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.description.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.skill.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.skill.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.skill(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
|
||||||
{% if form.skill.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.skill.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<p class="mt-1 text-xs text-gray-500">Ex: "Calculer", "Raisonner", "Communiquer"</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.max_points.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.max_points.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.max_points(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500", step="0.1") }}
|
|
||||||
{% if form.max_points.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.max_points.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="{{ form.grading_type.id }}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{{ form.grading_type.label.text }}
|
|
||||||
</label>
|
|
||||||
{{ form.grading_type(class="block w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500") }}
|
|
||||||
{% if form.grading_type.errors %}
|
|
||||||
<div class="mt-1 text-sm text-red-600">
|
|
||||||
{% for error in form.grading_type.errors %}
|
|
||||||
<p>{{ error }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Aide sur les types de notation -->
|
|
||||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
|
|
||||||
<h4 class="text-sm font-medium text-blue-900 mb-2">Types de notation</h4>
|
|
||||||
<div class="text-xs text-blue-800 space-y-1">
|
|
||||||
<p><strong>Points :</strong> Notation classique (ex: 2.5/4 points)</p>
|
|
||||||
<p><strong>Score :</strong> Évaluation par niveaux (0=non acquis, 1=en cours, 2=acquis, 3=expert, .=non évalué)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4 border-t border-gray-200">
|
|
||||||
<a href="{{ url_for('exercises.detail', assessment_id=assessment.id, id=exercise.id) }}" class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors">
|
|
||||||
Annuler
|
|
||||||
</a>
|
|
||||||
{{ form.submit(class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors") }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
<div class="text-sm font-medium text-gray-900">Gérer les élèves</div>
|
<div class="text-sm font-medium text-gray-900">Gérer les élèves</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('assessments.list') }}" class="block p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
<a href="{{ url_for('assessments.new') }}" class="block p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-2">
|
<div class="w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
<svg class="w-4 h-4 text-purple-600" fill="currentColor" viewBox="0 0 20 20">
|
<svg class="w-4 h-4 text-purple-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
|||||||
94
utils.py
Normal file
94
utils.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
from functools import wraps
|
||||||
|
from flask import current_app, flash, jsonify, request, render_template, redirect, url_for
|
||||||
|
from models import db
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError, IntegrityError
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
import logging
|
||||||
|
|
||||||
|
def handle_db_errors(f):
|
||||||
|
"""Décorateur pour gérer les erreurs de base de données"""
|
||||||
|
@wraps(f)
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
result = f(*args, **kwargs)
|
||||||
|
# Vérifier que le résultat est une réponse Flask valide
|
||||||
|
if result is None:
|
||||||
|
current_app.logger.error(f'Fonction {f.__name__} a retourné None')
|
||||||
|
return render_template('error.html', error="Une erreur inattendue s'est produite."), 500
|
||||||
|
return result
|
||||||
|
except IntegrityError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f'Erreur d\'intégrité dans {f.__name__}: {e}')
|
||||||
|
|
||||||
|
error_msg = "Une erreur s'est produite lors de l'enregistrement."
|
||||||
|
if "UNIQUE constraint failed" in str(e):
|
||||||
|
error_msg = "Cette donnée existe déjà."
|
||||||
|
elif "CHECK constraint failed" in str(e):
|
||||||
|
error_msg = "Les données saisies ne respectent pas les contraintes."
|
||||||
|
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 400
|
||||||
|
else:
|
||||||
|
flash(error_msg, 'error')
|
||||||
|
return render_template('error.html', error=error_msg), 400
|
||||||
|
|
||||||
|
except SQLAlchemyError as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f'Erreur SQLAlchemy dans {f.__name__}: {e}')
|
||||||
|
|
||||||
|
error_msg = "Une erreur de base de données s'est produite."
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
else:
|
||||||
|
flash(error_msg, 'error')
|
||||||
|
return render_template('error.html', error=error_msg), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
current_app.logger.error(f'Erreur inattendue dans {f.__name__}: {e}')
|
||||||
|
|
||||||
|
error_msg = "Une erreur inattendue s'est produite."
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({'success': False, 'error': error_msg}), 500
|
||||||
|
else:
|
||||||
|
flash(error_msg, 'error')
|
||||||
|
return render_template('error.html', error=error_msg), 500
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def safe_int_conversion(value, field_name="valeur"):
|
||||||
|
"""Conversion sécurisée en entier"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
raise ValueError(f"La {field_name} doit être un nombre entier valide.")
|
||||||
|
|
||||||
|
def safe_decimal_conversion(value, field_name="valeur"):
|
||||||
|
"""Conversion sécurisée en décimal"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(str(value))
|
||||||
|
except (ValueError, TypeError, InvalidOperation):
|
||||||
|
raise ValueError(f"La {field_name} doit être un nombre décimal valide.")
|
||||||
|
|
||||||
|
def validate_json_data(data, required_fields):
|
||||||
|
"""Valide les données JSON et vérifie les champs requis"""
|
||||||
|
if not data:
|
||||||
|
raise ValueError("Aucune donnée fournie.")
|
||||||
|
|
||||||
|
missing_fields = [field for field in required_fields if field not in data or data[field] is None]
|
||||||
|
if missing_fields:
|
||||||
|
raise ValueError(f"Champs requis manquants: {', '.join(missing_fields)}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def log_user_action(action, details=None):
|
||||||
|
"""Log les actions utilisateur pour audit"""
|
||||||
|
current_app.logger.info(f"Action utilisateur: {action}" + (f" - {details}" if details else ""))
|
||||||
|
|
||||||
|
class ValidationError(Exception):
|
||||||
|
"""Exception personnalisée pour les erreurs de validation"""
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user