fix: normalizar sync de google sheets para todos los dias, reparar 500 en ciclos lectivos, selector de ciclo lectivo en comisiones y opciones --keep-db --keep-users en install.sh
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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=<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/<SPREADSHEET_ID>/edit...)
|
||||
sheet_id_match = re.search(r'/spreadsheets/d/([a-zA-Z0-9-_]+)', url)
|
||||
# 2. Enlace de edición o exportación (/d/<SPREADSHEET_ID>/...)
|
||||
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'])
|
||||
|
||||
+65
-58
@@ -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...")
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
<div class="modal fade text-start" id="modalEditTerm{{ term.id }}" tabindex="-1" aria-labelledby="modalEditTermTitle{{ term.id }}" aria-hidden="true" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content shadow-lg border-0">
|
||||
<form method="POST" action="{{ url_for('admin.academic_term_edit', id=term.id) }}">
|
||||
<form method="POST" action="/admin/academic_term_edit/{{ term.id }}">
|
||||
<input type="hidden" name="id" value="{{ term.id }}">
|
||||
<div class="modal-header border-bottom">
|
||||
<h5 class="modal-title h6 fw-bold" id="modalEditTermTitle{{ term.id }}">
|
||||
|
||||
@@ -277,9 +277,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Período, Año y Turno -->
|
||||
<!-- 2. Ciclo Lectivo, Período, Año y Turno -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-6">
|
||||
<label for="add_academic_term_id" class="form-label fw-semibold small">
|
||||
<i class="bi bi-calendar-range text-primary me-1"></i>Ciclo Lectivo
|
||||
</label>
|
||||
<select class="form-select form-select-sm" id="add_academic_term_id" name="academic_term_id">
|
||||
<option value="">-- Seleccionar Ciclo Lectivo --</option>
|
||||
{% for term in academic_terms %}
|
||||
<option value="{{ term.id }}" data-year="{{ term.year }}" data-period="{{ term.period_type }}" data-code="{{ term.code }}" {% if term.is_current %}selected{% endif %}>
|
||||
{{ term.name }} ({{ term.code }}){% if term.is_current %} [Actual]{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text small">Asocia la comisión al calendario académico.</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="add_semester" class="form-label fw-semibold small">Cuatrimestre / Período</label>
|
||||
<select class="form-select form-select-sm" id="add_semester" name="semester">
|
||||
<option value="1C" selected>1C (Primer Cuatrimestre)</option>
|
||||
@@ -287,11 +301,13 @@
|
||||
<option value="Anual">Anual</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<label for="add_year" class="form-label fw-semibold small">Año Académico</label>
|
||||
<input type="number" class="form-control form-control-sm" id="add_year" name="year" value="2026" min="2020" max="2035">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label for="add_shift" class="form-label fw-semibold small">Turno de Cursada</label>
|
||||
<select class="form-select form-select-sm" id="add_shift" name="shift">
|
||||
<option value="Mañana">Mañana (08:00 - 12:00)</option>
|
||||
@@ -349,4 +365,36 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const termSelect = document.getElementById('add_academic_term_id');
|
||||
const yearInput = document.getElementById('add_year');
|
||||
const semSelect = document.getElementById('add_semester');
|
||||
|
||||
function syncTerm() {
|
||||
if (!termSelect) return;
|
||||
const opt = termSelect.options[termSelect.selectedIndex];
|
||||
if (!opt || !opt.value) return;
|
||||
const yr = opt.getAttribute('data-year');
|
||||
const period = (opt.getAttribute('data-period') || opt.text || '').toLowerCase();
|
||||
const code = (opt.getAttribute('data-code') || '').toLowerCase();
|
||||
if (yr && yearInput) yearInput.value = yr;
|
||||
if (semSelect) {
|
||||
if (period.includes('segundo') || code.includes('2c') || code.includes('2º') || code.includes('2do') || code.includes('-2')) {
|
||||
semSelect.value = '2C';
|
||||
} else if (period.includes('anual') || code.includes('anual')) {
|
||||
semSelect.value = 'Anual';
|
||||
} else if (period.includes('primer') || code.includes('1c') || code.includes('1º') || code.includes('1ro') || code.includes('-1')) {
|
||||
semSelect.value = '1C';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (termSelect) {
|
||||
termSelect.addEventListener('change', syncTerm);
|
||||
if (termSelect.value) syncTerm();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+14
-1
@@ -20,11 +20,15 @@ hdr() { echo -e "\n${BOLD}${CYAN}══ $* ══${RESET}\n"; }
|
||||
|
||||
# ─── Argumentos de Línea de Comandos ───────────────────────────────────────────
|
||||
ARG_DB_ACTION=""
|
||||
ARG_KEEP_USERS=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep-db)
|
||||
ARG_DB_ACTION="keep"
|
||||
;;
|
||||
--keep-users)
|
||||
ARG_KEEP_USERS=true
|
||||
;;
|
||||
--fresh-db|--restore-db)
|
||||
ARG_DB_ACTION="fresh"
|
||||
;;
|
||||
@@ -32,6 +36,7 @@ for arg in "$@"; do
|
||||
echo "Uso: sudo bash install.sh [OPCIONES]"
|
||||
echo "Opciones:"
|
||||
echo " --keep-db Mantiene la base de datos existente si ya contiene datos."
|
||||
echo " --keep-users Preserva los usuarios existentes sin volver a crearlos o pisar sus contraseñas."
|
||||
echo " --fresh-db Restaura la base de datos desde cero (DROP y recarga snapshot)."
|
||||
echo " -h, --help Muestra esta ayuda."
|
||||
exit 0
|
||||
@@ -110,6 +115,8 @@ info "Directorio Raíz: ${ROOT_DIR}"
|
||||
info "Directorio Backend: ${BACKEND_DIR}"
|
||||
info "Directorio Frontend: ${FRONTEND_DIR}"
|
||||
info "Usuario de Servicio: ${APP_USER}"
|
||||
info "Preservar Base de Datos: ${ARG_DB_ACTION:-auto (interactivo/detección)}"
|
||||
info "Preservar Usuarios: ${ARG_KEEP_USERS}"
|
||||
|
||||
# ─── 1. Paquetes Base del Sistema ──────────────────────────────────────────────
|
||||
hdr "1. Actualizando repositorios y paquetes base"
|
||||
@@ -424,8 +431,14 @@ done
|
||||
|
||||
# Sincronización idempotente con init_db.py (crea tablas o columnas faltantes sin borrar datos)
|
||||
info "Sincronizando modelos y esquemas institucionales con init_db.py..."
|
||||
INIT_DB_EXTRA_FLAGS=()
|
||||
if [ "$ARG_KEEP_USERS" = true ]; then
|
||||
INIT_DB_EXTRA_FLAGS+=( "--keep-users" )
|
||||
info "Bandera --keep-users activada: init_db.py preservará los usuarios y contraseñas sin modificarlos ni recrearlos."
|
||||
fi
|
||||
|
||||
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 PYTHONUTF8=1 PYTHONIOENCODING=utf-8 \
|
||||
PYTHONPATH="${BACKEND_DIR}" FLASK_APP=wsgi.py "${VENV_DIR}/bin/python" -X utf8 init_db.py
|
||||
PYTHONPATH="${BACKEND_DIR}" FLASK_APP=wsgi.py "${VENV_DIR}/bin/python" -X utf8 init_db.py "${INIT_DB_EXTRA_FLAGS[@]}"
|
||||
ok "Esquema, usuarios y roles RBAC sincronizados con éxito"
|
||||
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
Reference in New Issue
Block a user