feat: completar fases 3 y 4 del roadmap mvp (gradebook, drag-and-drop, impersonacion) y actualizar README
This commit is contained in:
@@ -11,6 +11,7 @@ from .milestone import MilestoneType, AcademicMilestone, BASE_MILESTONE_TYPES
|
||||
from .audit_log import AuditLog
|
||||
from .enrollment import StudentEnrollment
|
||||
from .setting import SystemSetting
|
||||
from .grade import MilestoneGrade
|
||||
|
||||
__all__ = [
|
||||
'User',
|
||||
@@ -34,5 +35,6 @@ __all__ = [
|
||||
'BASE_MILESTONE_TYPES',
|
||||
'AuditLog',
|
||||
'StudentEnrollment',
|
||||
'SystemSetting'
|
||||
'SystemSetting',
|
||||
'MilestoneGrade'
|
||||
]
|
||||
@@ -390,28 +390,61 @@ class ReservationOptimizer:
|
||||
}
|
||||
|
||||
def _calculate_preferred_time(self, commission: Commission) -> Dict:
|
||||
"""Calculate preferred time slots for a commission"""
|
||||
# This is a simplified version - would need to be based on actual semester schedule
|
||||
"""Calculate preferred time slots for a commission based on shift and schedule"""
|
||||
current_date = datetime.utcnow()
|
||||
|
||||
# Default to weekday mornings for regular classes
|
||||
days_ahead = (0 - current_date.weekday()) % 7 # Next Monday
|
||||
if days_ahead == 0: # If today is Monday, use next week
|
||||
# Determine day of week from commission schedule or default to Monday
|
||||
target_weekday = 0 # Monday
|
||||
sched = (commission.schedule or '').lower()
|
||||
if 'martes' in sched:
|
||||
target_weekday = 1
|
||||
elif 'miercoles' in sched or 'miércoles' in sched:
|
||||
target_weekday = 2
|
||||
elif 'jueves' in sched:
|
||||
target_weekday = 3
|
||||
elif 'viernes' in sched:
|
||||
target_weekday = 4
|
||||
elif 'sabado' in sched or 'sábado' in sched:
|
||||
target_weekday = 5
|
||||
|
||||
days_ahead = (target_weekday - current_date.weekday()) % 7
|
||||
if days_ahead == 0:
|
||||
days_ahead = 7
|
||||
|
||||
preferred_date = current_date + timedelta(days=days_ahead)
|
||||
|
||||
# Determine hours based on shift
|
||||
shift = (commission.shift or '').strip().lower()
|
||||
if 'mañana' in shift or 'manana' in shift:
|
||||
start_h, start_m = 8, 0
|
||||
end_h, end_m = 12, 0
|
||||
elif 'tarde' in shift:
|
||||
start_h, start_m = 14, 0
|
||||
end_h, end_m = 18, 0
|
||||
elif 'noche' in shift or 'vespertino' in shift:
|
||||
start_h, start_m = 18, 30
|
||||
end_h, end_m = 22, 30
|
||||
else:
|
||||
start_h, start_m = 9, 0
|
||||
end_h, end_m = 13, 0
|
||||
|
||||
return {
|
||||
'start': preferred_date.replace(hour=9, minute=0, second=0, microsecond=0),
|
||||
'end': preferred_date.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||
'start': preferred_date.replace(hour=start_h, minute=start_m, second=0, microsecond=0),
|
||||
'end': preferred_date.replace(hour=end_h, minute=end_m, second=0, microsecond=0)
|
||||
}
|
||||
|
||||
def apply_optimized_reservations(self, reservation_data: List[Dict]) -> List[Reservation]:
|
||||
"""Apply the optimized reservations to the database"""
|
||||
"""Apply the optimized reservations to the database with ISO string parsing and conflict avoidance"""
|
||||
applied_reservations = []
|
||||
|
||||
for data in reservation_data:
|
||||
# Check for conflicts before creating
|
||||
for item in reservation_data:
|
||||
data = dict(item)
|
||||
if isinstance(data.get('start_time'), str):
|
||||
data['start_time'] = datetime.fromisoformat(data['start_time'].replace('Z', '+00:00'))
|
||||
if isinstance(data.get('end_time'), str):
|
||||
data['end_time'] = datetime.fromisoformat(data['end_time'].replace('Z', '+00:00'))
|
||||
|
||||
# Check for physical room conflicts before creating
|
||||
conflicts = Reservation.find_conflicts(
|
||||
data['classroom_id'],
|
||||
data['start_time'],
|
||||
@@ -419,12 +452,17 @@ class ReservationOptimizer:
|
||||
)
|
||||
|
||||
if not conflicts:
|
||||
# Ensure status enum value compatibility
|
||||
if 'status' in data and isinstance(data['status'], str):
|
||||
try:
|
||||
data['status'] = ReservationStatus(data['status']).value
|
||||
except ValueError:
|
||||
data['status'] = ReservationStatus.CONFIRMED.value
|
||||
reservation = Reservation(**data)
|
||||
db.session.add(reservation)
|
||||
applied_reservations.append(reservation)
|
||||
else:
|
||||
# Log conflict - could be handled differently
|
||||
print(f"Conflict detected for reservation data: {data}")
|
||||
current_app.logger.warning(f"Conflict detected for reservation during genetic apply: {data}")
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class MilestoneGrade(db.Model):
|
||||
"""
|
||||
Calificación y seguimiento pedagógico individual de un alumno en un hito evaluativo (Fase 3 MVP).
|
||||
Vincula un AcademicMilestone con un estudiante matriculado (StudentEnrollment / User).
|
||||
"""
|
||||
__tablename__ = 'milestone_grades'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
milestone_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('academic_milestones.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
enrollment_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('student_enrollments.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
student_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
score = db.Column(db.Float, nullable=True) # Calificación numérica de 1.0 a 10.0
|
||||
is_absent = db.Column(db.Boolean, default=False) # Flag de Ausente
|
||||
feedback = db.Column(db.Text, nullable=True) # Devolución formativa/pedagógica
|
||||
graded_by_user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||
nullable=True
|
||||
)
|
||||
graded_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Restricción de unicidad: un estudiante tiene una única nota por hito
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('milestone_id', 'student_id', name='uq_milestone_student'),
|
||||
)
|
||||
|
||||
# Relaciones
|
||||
milestone = db.relationship('AcademicMilestone', backref=db.backref('grades', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
enrollment = db.relationship('StudentEnrollment', backref=db.backref('milestone_grades', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
student = db.relationship('User', foreign_keys=[student_id], backref=db.backref('received_grades', lazy='dynamic'))
|
||||
graded_by = db.relationship('User', foreign_keys=[graded_by_user_id])
|
||||
|
||||
@property
|
||||
def is_passed(self) -> bool:
|
||||
"""Determina si la nota está aprobada (>= 4.00 y no ausente)."""
|
||||
if self.is_absent or self.score is None:
|
||||
return False
|
||||
return self.score >= 4.0
|
||||
|
||||
@property
|
||||
def display_score(self) -> str:
|
||||
"""Representación textual estándar: 'Ausente', '7.5' o '-'."""
|
||||
if self.is_absent:
|
||||
return 'Ausente'
|
||||
if self.score is not None:
|
||||
return f'{self.score:.1f}' if self.score % 1 != 0 else f'{int(self.score)}'
|
||||
return '-'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'milestone_id': self.milestone_id,
|
||||
'milestone_title': self.milestone.title if self.milestone else None,
|
||||
'milestone_type': self.milestone.tipo if self.milestone else None,
|
||||
'milestone_color': self.milestone.color if self.milestone else None,
|
||||
'enrollment_id': self.enrollment_id,
|
||||
'student_id': self.student_id,
|
||||
'student_name': self.student.name if self.student else None,
|
||||
'student_email': self.student.email if self.student else None,
|
||||
'score': self.score,
|
||||
'display_score': self.display_score,
|
||||
'is_absent': bool(self.is_absent),
|
||||
'is_passed': self.is_passed,
|
||||
'feedback': self.feedback or '',
|
||||
'graded_by_user_id': self.graded_by_user_id,
|
||||
'graded_by_name': self.graded_by.name if self.graded_by else None,
|
||||
'graded_at': self.graded_at.isoformat() if self.graded_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<MilestoneGrade milestone={self.milestone_id} student={self.student_id} score={self.display_score}>'
|
||||
@@ -54,6 +54,13 @@ class Commission(db.Model):
|
||||
shift = db.Column(db.String(30), nullable=True) # Mañana, Tarde, Vespertino, Noche
|
||||
virtual_link = db.Column(db.String(500), nullable=True) # Link Zoom, Meet, Teams, Moodle
|
||||
active = db.Column(db.Boolean, default=True) # Changed from is_active to active
|
||||
|
||||
# Cierre de Actas y Libro de Calificaciones (Fase 3 MVP)
|
||||
grades_closed = db.Column(db.Boolean, default=False, nullable=True)
|
||||
grades_closed_at = db.Column(db.DateTime, nullable=True)
|
||||
grades_closed_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
acta_number = db.Column(db.String(50), nullable=True)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
@@ -69,6 +76,7 @@ class Commission(db.Model):
|
||||
# Relationships
|
||||
pass # Reservation already defines the relationship back to commission
|
||||
teacher = db.relationship('User', backref='teaching_commissions', foreign_keys=[teacher_id])
|
||||
closed_by_user = db.relationship('User', foreign_keys=[grades_closed_by])
|
||||
|
||||
# Virtual field for teacher_name
|
||||
@property
|
||||
@@ -125,6 +133,11 @@ class Commission(db.Model):
|
||||
'shift': self.shift,
|
||||
'virtual_link': self.virtual_link,
|
||||
'active': self.active,
|
||||
'grades_closed': bool(self.grades_closed),
|
||||
'grades_closed_at': self.grades_closed_at.isoformat() if self.grades_closed_at else None,
|
||||
'grades_closed_by': self.grades_closed_by,
|
||||
'closed_by_name': self.closed_by_user.name if self.closed_by_user else None,
|
||||
'acta_number': self.acta_number,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'full_code': self.get_full_code(),
|
||||
|
||||
Reference in New Issue
Block a user