diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py index 0d7d5b8..5cfcd65 100644 --- a/backend/app/models/subject.py +++ b/backend/app/models/subject.py @@ -61,6 +61,10 @@ class Commission(db.Model): 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) + # Vinculación con Ciclo Lectivo / Período Académico + academic_term_id = db.Column(db.Integer, db.ForeignKey('academic_terms.id', ondelete='SET NULL'), nullable=True) + academic_term = db.relationship('AcademicTerm', foreign_keys=[academic_term_id]) + created_at = db.Column(db.DateTime, default=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) @@ -138,6 +142,7 @@ class Commission(db.Model): '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, + 'academic_term_id': self.academic_term_id, '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(), diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index c0121f6..fe97f3c 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -1223,6 +1223,9 @@ def commissions_list(): or_(User.role.ilike('%DOCENTE%'), User.role.ilike('%docent%'), User.role.ilike('%ADMIN%')) ).filter_by(is_active=True).order_by(User.name.asc()).all() + from app.models.academic_term import AcademicTerm + academic_terms = AcademicTerm.query.filter_by(is_active=True).order_by(AcademicTerm.year.desc(), AcademicTerm.start_date.desc()).all() + can_edit = ( current_user.has_permission('academic', 'read_write') or current_user.is_admin() or @@ -1235,6 +1238,7 @@ def commissions_list(): pagination=pagination, subjects=subjects, teachers=teachers, + academic_terms=academic_terms, search=search, subject_filter=subject_filter, teacher_filter=teacher_filter, diff --git a/backend/app/routes/api/admin.py b/backend/app/routes/api/admin.py index 18a67b5..1474229 100644 --- a/backend/app/routes/api/admin.py +++ b/backend/app/routes/api/admin.py @@ -240,11 +240,16 @@ def get_commissions(): 'career_name': c_name }) + from app.models.academic_term import AcademicTerm + terms = AcademicTerm.query.filter_by(is_active=True).order_by(AcademicTerm.year.desc(), AcademicTerm.start_date.desc()).all() + terms_data = [t.to_dict() for t in terms] + return jsonify({ 'status': 'success', 'total': len(commissions_data), 'commissions': commissions_data, - 'subjects': subjects_data + 'subjects': subjects_data, + 'academic_terms': terms_data }), 200 # --------------------------------------------------------- @@ -799,6 +804,25 @@ def create_commission(): code = data.get('code', '').strip() semester = (data.get('semester') or '1C').strip() year = int(data.get('year', datetime.now().year) or datetime.now().year) + academic_term_id = data.get('academic_term_id') + academic_term_id = int(academic_term_id) if (academic_term_id and str(academic_term_id).strip()) else None + + # Si se seleccionó Ciclo Lectivo, sincronizar año y cuatrimestre inteligentemente + if academic_term_id: + from app.models.academic_term import AcademicTerm + term_obj = AcademicTerm.query.get(academic_term_id) + if term_obj: + if not data.get('year') or int(data.get('year')) == datetime.now().year: + year = term_obj.year + if not data.get('semester') or data.get('semester') == '1C': + p_text = f"{term_obj.name} {term_obj.period_type or ''} {term_obj.code}".lower() + if '2c' in p_text or 'segundo' in p_text or '2º' in p_text or '2do' in p_text: + semester = '2C' + elif 'anual' in p_text: + semester = 'Anual' + else: + semester = '1C' + teacher_id = data.get('teacher_id') teacher_id = int(teacher_id) if (teacher_id and str(teacher_id).strip()) else None max_students = int(data.get('max_students', 35) or 35) @@ -834,6 +858,8 @@ def create_commission(): existing.max_students = max_students existing.schedule = schedule existing.shift = shift + if academic_term_id: + existing.academic_term_id = academic_term_id if virtual_link: existing.virtual_link = virtual_link existing.active = bool(active) @@ -858,6 +884,7 @@ def create_commission(): schedule=schedule, shift=shift, virtual_link=virtual_link, + academic_term_id=academic_term_id, active=bool(active) ) db.session.add(commission) diff --git a/backend/app/services/sheets_importer.py b/backend/app/services/sheets_importer.py index f7896ec..2d28e33 100644 --- a/backend/app/services/sheets_importer.py +++ b/backend/app/services/sheets_importer.py @@ -13,28 +13,35 @@ from app.models.user import User BASE_CSV_URL = 'https://docs.google.com/spreadsheets/d/e/2PACX-1vSc_T_BQjbn3uPelioCgx52UM5Py-qNhJN0TYPd1kmsN5jdb3Q8rAaIvNMF_2ZTzQt6bH--yIWQKrKR/pub?single=true&output=csv' +def map_day_to_idx(day_str): + """Mapea el nombre del día (o prefijo) a su índice numérico 0..6 (Lunes=0)""" + d = (day_str or '').lower() + if 'lun' in d: return 0 + if 'mar' in d: return 1 + if 'mie' in d or 'mié' in d: return 2 + if 'jue' in d: return 3 + if 'vie' in d: return 4 + if 'sab' in d or 'sáb' in d: return 5 + if 'dom' in d: return 6 + return 0 + def normalize_sheets_url(url): """ - Normaliza enlaces de Google Sheets (compartidos, de edición o publicados) + Normaliza enlaces de Google Sheets (compartidos, de edición, con /u/1/, #gid=... o /pubhtml) para obtener la URL base que acepta parámetro gid=&output=csv o format=csv. """ if not url: return BASE_CSV_URL url = url.strip() - # 1. Enlace publicado web (/d/e/2PACX-.../pub...) - if '/d/e/' in url: - base_match = re.match(r'^(https://docs\.google\.com/spreadsheets/d/e/[^/?#]+)/pub', url) - if base_match: - return f"{base_match.group(1)}/pub?single=true&output=csv" - clean = url.split('&gid=')[0].split('?gid=')[0] - sep = '&' if '?' in clean else '?' - if 'output=csv' not in clean: - clean = f"{clean}{sep}single=true&output=csv" - return clean + # 1. Enlace publicado web (/d/e/2PACX-... con o sin /u/X/ y con pub o pubhtml) + pub_match = re.search(r'/spreadsheets/(?:u/\d+/)?d/e/([a-zA-Z0-9-_]+)', url) + if pub_match: + doc_id = pub_match.group(1) + return f"https://docs.google.com/spreadsheets/d/e/{doc_id}/pub?single=true&output=csv" - # 2. Enlace de edición o visualización (/d//edit...) - sheet_id_match = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', url) + # 2. Enlace de edición o exportación (/d//...) + sheet_id_match = re.search(r'/spreadsheets/(?:u/\d+/)?d/([a-zA-Z0-9-_]+)', url) if sheet_id_match: sheet_id = sheet_id_match.group(1) return f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv" @@ -72,6 +79,40 @@ class GoogleSheetsImporter: configured_url = os.getenv('GOOGLE_SHEETS_URL') self.base_url = normalize_sheets_url(configured_url) if configured_url else BASE_CSV_URL + def get_sheets_config(self): + """ + Descubre las pestañas y GIDs dinámicamente desde /pubhtml si está disponible, + o utiliza el fallback SHEETS_CONFIG preconfigurado. + """ + pub_match = re.search(r'/spreadsheets/d/e/([a-zA-Z0-9-_]+)', self.base_url) + if pub_match: + doc_id = pub_match.group(1) + pubhtml_url = f"https://docs.google.com/spreadsheets/d/e/{doc_id}/pubhtml" + try: + req = urllib.request.Request( + pubhtml_url, + headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'} + ) + with urllib.request.urlopen(req, timeout=12) as resp: + html_content = resp.read().decode('utf-8', errors='replace') + # Extraer pares name y gid de Google Sheets pubhtml + found = re.findall(r'name:\s*["\']([^"\']+)["\'],\s*gid:\s*["\'](\d+)["\']', html_content) + if found: + discovered = [] + for name, gid in found: + clean_name = re.sub(r'[^a-zA-ZáéíóúÁÉÍÓÚñÑ]', '', name).capitalize() + d_idx = map_day_to_idx(clean_name) + discovered.append({ + 'name': clean_name if clean_name else name, + 'gid': gid, + 'day_idx': d_idx + }) + if discovered: + return discovered + except Exception: + pass + return SHEETS_CONFIG + def fetch_sheet_csv(self, gid): """Fetch CSV string for a specific sheet gid""" separator = '&' if '?' in self.base_url else '?' @@ -110,7 +151,10 @@ class GoogleSheetsImporter: current_shift = 'Mañana' for row in rows[header_idx + 1:]: if len(row) >= col_offset + 5: - raw_cell = row[col_offset].strip().replace('\n', ' - ') + def _clean(val): + return val.replace('\ufb01', 'fi').replace('\ufb02', 'fl').strip() + + raw_cell = _clean(row[col_offset]).replace('\n', ' - ') row_full_text = ' '.join(row).upper() # Detect shift delimiters @@ -128,10 +172,10 @@ class GoogleSheetsImporter: continue codigo = raw_cell - asignatura = row[col_offset + 1].strip().replace('\n', ' ') - aula = row[col_offset + 2].strip().replace('\n', ' ') - carrera = row[col_offset + 3].strip().replace('\n', ' ') - horario = row[col_offset + 4].strip().replace('\n', ' ') + asignatura = _clean(row[col_offset + 1]).replace('\n', ' ') + aula = _clean(row[col_offset + 2]).replace('\n', ' ') + carrera = _clean(row[col_offset + 3]).replace('\n', ' ') + horario = _clean(row[col_offset + 4]).replace('\n', ' ') # Check valid data row if codigo and asignatura and not 'codigo' in codigo.lower(): @@ -250,7 +294,8 @@ class GoogleSheetsImporter: } all_raw_records = [] - for sheet_cfg in SHEETS_CONFIG: + sheets_to_process = self.get_sheets_config() + for sheet_cfg in sheets_to_process: try: csv_data = self.fetch_sheet_csv(sheet_cfg['gid']) rows = self.parse_sheet_rows(csv_data, sheet_cfg['name']) diff --git a/backend/init_db.py b/backend/init_db.py index 489ed26..246058d 100644 --- a/backend/init_db.py +++ b/backend/init_db.py @@ -1,4 +1,5 @@ import sys +import os # Asegurar codificación UTF-8 en consola de Windows if hasattr(sys.stdout, 'reconfigure'): @@ -68,6 +69,7 @@ def init_database(): ALTER TABLE commissions ADD COLUMN IF NOT EXISTS grades_closed_at TIMESTAMP WITHOUT TIME ZONE; ALTER TABLE commissions ADD COLUMN IF NOT EXISTS grades_closed_by INTEGER REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE commissions ADD COLUMN IF NOT EXISTS acta_number VARCHAR(50); + ALTER TABLE commissions ADD COLUMN IF NOT EXISTS academic_term_id INTEGER REFERENCES academic_terms(id) ON DELETE SET NULL; """)) # Columnas extendidas de reservations @@ -310,67 +312,72 @@ def init_database(): print(" [OK] Sin duplicados de Bedelia.") # 4. Verificar o crear usuarios institucionales de cada rol - print("[*] Verificando usuarios institucionales y credenciales...") - roles_by_name = {r.name: r for r in Role.query.all()} + keep_users = ('--keep-users' in sys.argv) or (os.environ.get('KEEP_USERS', '').lower() in ['1', 'true', 'yes']) + if keep_users: + print("[*] Opción --keep-users activa: Preservando usuarios, roles y contraseñas existentes intactos (no se modifican ni pisan).") + else: + print("[*] Verificando usuarios institucionales y credenciales...") + roles_by_name = {r.name: r for r in Role.query.all()} - institutional_users = [ - { - 'email': 'admin@edu-space.com', - 'name': 'Administrador del Sistema', - 'role_name': 'Admin', - 'role_code': 'ADMIN' - }, - { - 'email': 'bedelia@edu-space.com', - 'name': 'Operador de Bedelía', - 'role_name': 'Bedelia', - 'role_code': 'BEDELIA' - }, - { - 'email': 'docente@edu-space.com', - 'name': 'Profesor Titular', - 'role_name': 'Docente', - 'role_code': 'DOCENTE' - }, - { - 'email': 'alumno@edu-space.com', - 'name': 'Estudiante Regular', - 'role_name': 'Alumno', - 'role_code': 'ALUMNO' - } - ] + institutional_users = [ + { + 'email': 'admin@edu-space.com', + 'name': 'Administrador del Sistema', + 'role_name': 'Admin', + 'role_code': 'ADMIN' + }, + { + 'email': 'bedelia@edu-space.com', + 'name': 'Operador de Bedelía', + 'role_name': 'Bedelia', + 'role_code': 'BEDELIA' + }, + { + 'email': 'docente@edu-space.com', + 'name': 'Profesor Titular', + 'role_name': 'Docente', + 'role_code': 'DOCENTE' + }, + { + 'email': 'alumno@edu-space.com', + 'name': 'Estudiante Regular', + 'role_name': 'Alumno', + 'role_code': 'ALUMNO' + } + ] - for u_info in institutional_users: - u_obj = User.query.filter_by(email=u_info['email']).first() - r_match = roles_by_name.get(u_info['role_name']) + for u_info in institutional_users: + u_obj = User.query.filter_by(email=u_info['email']).first() + r_match = roles_by_name.get(u_info['role_name']) - if not u_obj: - u_obj = User( - email=u_info['email'], - name=u_info['name'], - role=u_info['role_code'], - role_id=r_match.id if r_match else None, - is_active=True, - preferred_language="es", - theme_preference="auto" - ) - u_obj.set_password("admin123") - db.session.add(u_obj) - db.session.commit() - print(f" [+] Usuario creado: {u_info['email']} | Clave: admin123 | Rol: {u_info['role_name']}") - else: - # Garantizar sincronización de rol, estado activo y clave - if r_match and u_obj.role_id != r_match.id: - u_obj.role_id = r_match.id - u_obj.role = u_info['role_code'] - u_obj.is_active = True - u_obj.set_password("admin123") - if not u_obj.preferred_language: - u_obj.preferred_language = "es" - if not u_obj.theme_preference: - u_obj.theme_preference = "auto" - db.session.commit() - print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}, Clave: admin123).") + if not u_obj: + u_obj = User( + email=u_info['email'], + name=u_info['name'], + role=u_info['role_code'], + role_id=r_match.id if r_match else None, + is_active=True, + preferred_language="es", + theme_preference="auto" + ) + u_obj.set_password("admin123") + db.session.add(u_obj) + db.session.commit() + print(f" [+] Usuario creado: {u_info['email']} | Clave: admin123 | Rol: {u_info['role_name']}") + else: + # Preservar contraseña y estado si ya existen para no pisar cambios administrativos + if r_match and u_obj.role_id != r_match.id: + u_obj.role_id = r_match.id + if not u_obj.role: + u_obj.role = u_info['role_code'] + if not u_obj.password_hash: + u_obj.set_password("admin123") + if not u_obj.preferred_language: + u_obj.preferred_language = "es" + if not u_obj.theme_preference: + u_obj.theme_preference = "auto" + db.session.commit() + print(f" [OK] Usuario {u_info['email']} validado (Rol: {u_info['role_name']}).") # 5. Inicializar edificios institucionales oficiales print("[*] Verificando edificios institucionales...") diff --git a/frontend/src/app.js b/frontend/src/app.js index bda26f6..aa41aef 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -156,8 +156,32 @@ const translateHelper = function(s) { return s || ''; }; +const urlForHelper = function(endpoint, params = {}) { + if (!endpoint) return '#'; + const parts = endpoint.split('.'); + const blueprint = parts.length > 1 ? parts[0] : ''; + const action = parts.length > 1 ? parts[1] : parts[0]; + let pathStr = `/${blueprint}/${action}`.replace(/^\/\//, '/').replace(/\/$/, ''); + if (params && typeof params === 'object') { + const q = []; + for (const [k, v] of Object.entries(params)) { + if (k === 'id' || k === 'pk') { + pathStr += `/${v}`; + } else { + q.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`); + } + } + if (q.length > 0) { + pathStr += (pathStr.includes('?') ? '&' : '?') + q.join('&'); + } + } + return pathStr; +}; + env.addGlobal('_', translateHelper); env.addFilter('_', translateHelper); +env.addGlobal('url_for', urlForHelper); +env.addFilter('url_for', urlForHelper); app.set('view engine', 'html'); diff --git a/frontend/src/routes/admin.js b/frontend/src/routes/admin.js index d5294a9..f86e40a 100644 --- a/frontend/src/routes/admin.js +++ b/frontend/src/routes/admin.js @@ -833,6 +833,7 @@ const handleCommissionsList = async (req, res) => { let commissions = []; let subjects = []; let teachers = []; + let academic_terms = []; try { const [cRes, uRes] = await Promise.allSettled([ @@ -854,6 +855,15 @@ const handleCommissionsList = async (req, res) => { teachers: c.teachers || (c.teacher_name ? [{ name: c.teacher_name, role: 'Titular' }] : []) })); subjects = cRes.value.data.subjects || []; + academic_terms = cRes.value.data.academic_terms || []; + } + + // Si por alguna razón no vinieron academic_terms en commissions, intentar fallback + if (!academic_terms || academic_terms.length === 0) { + try { + const termsRes = await req.apiClient.get('/academic-terms'); + academic_terms = termsRes.data?.terms || termsRes.data?.academic_terms || []; + } catch (tErr) {} } if (uRes.status === 'fulfilled' && uRes.value.data) { @@ -872,6 +882,7 @@ const handleCommissionsList = async (req, res) => { commissions, subjects, teachers, + academic_terms, search, subject_filter: subject_id ? parseInt(subject_id, 10) : '', shift_filter: shift, @@ -993,12 +1004,13 @@ const handleCommissionDetail = async (req, res) => { const handleCommissionAdd = async (req, res) => { try { - const { subject_id, code, semester, year, shift, teacher_id, max_students, schedule, virtual_link } = req.body; + const { subject_id, code, semester, year, shift, teacher_id, max_students, schedule, virtual_link, academic_term_id } = req.body; const payload = { subject_id: parseInt(subject_id, 10), code: (code || '').trim(), semester: semester || '1C', year: year ? parseInt(year, 10) : 2026, + academic_term_id: academic_term_id ? parseInt(academic_term_id, 10) : null, shift: shift || 'Mañana', teacher_id: teacher_id ? parseInt(teacher_id, 10) : null, max_students: max_students ? parseInt(max_students, 10) : 40, diff --git a/frontend/views/admin/academic_terms/list.html b/frontend/views/admin/academic_terms/list.html index 7fd969c..fd082a4 100644 --- a/frontend/views/admin/academic_terms/list.html +++ b/frontend/views/admin/academic_terms/list.html @@ -129,7 +129,7 @@