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() | ||||||
|  |  | ||||||
| @@ -11,6 +13,7 @@ class ClassGroup(db.Model): | |||||||
|     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}>' | ||||||
|  |  | ||||||
| @@ -25,13 +28,17 @@ 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) | ||||||
|     title = db.Column(db.String(200), nullable=False) |     title = db.Column(db.String(200), nullable=False) | ||||||
|     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( | ||||||
|  |         joinedload(Exercise.grading_elements) | ||||||
|  |     ).filter_by(id=id, assessment_id=assessment_id).first_or_404() | ||||||
|     return render_template('exercise_detail.html', assessment=assessment, exercise=exercise) |     return render_template('exercise_detail.html', assessment=assessment, exercise=exercise) | ||||||
|  |  | ||||||
| @bp.route('/assessments/<int:assessment_id>/exercises/<int:id>/edit', methods=['GET', 'POST']) |  | ||||||
| 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