414 lines
17 KiB
Python
414 lines
17 KiB
Python
import urllib.request
|
|
import csv
|
|
import io
|
|
import re
|
|
from datetime import datetime, date, time, timedelta
|
|
from app import db
|
|
from app.models.classroom import Classroom
|
|
from app.models.career import Career
|
|
from app.models.subject import Subject, Commission
|
|
from app.models.reservation import Reservation, ReservationStatus
|
|
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'
|
|
|
|
SHEETS_CONFIG = [
|
|
{ 'name': 'Lunes', 'gid': '1915752353', 'day_idx': 0 },
|
|
{ 'name': 'Martes', 'gid': '466778263', 'day_idx': 1 },
|
|
{ 'name': 'Miércoles', 'gid': '615483345', 'day_idx': 2 },
|
|
{ 'name': 'Jueves', 'gid': '682416571', 'day_idx': 3 },
|
|
{ 'name': 'Viernes', 'gid': '1162906711', 'day_idx': 4 },
|
|
{ 'name': 'Sábado', 'gid': '752305601', 'day_idx': 5 },
|
|
]
|
|
|
|
TIME_REGEX = re.compile(r'(\d{1,2})(?::(\d{2}))?\s*(?:a|-|to)\s*(\d{1,2})(?::(\d{2}))?', re.IGNORECASE)
|
|
|
|
class GoogleSheetsImporter:
|
|
"""
|
|
Service to fetch, parse, and synchronize academic schedules,
|
|
classrooms, careers, and subjects from the official UniCABA Google Sheet.
|
|
"""
|
|
|
|
def __init__(self, base_url=None):
|
|
self.base_url = base_url or BASE_CSV_URL
|
|
|
|
def fetch_sheet_csv(self, gid):
|
|
"""Fetch CSV string for a specific sheet gid"""
|
|
url = f"{self.base_url}&gid={gid}"
|
|
req = urllib.request.Request(
|
|
url,
|
|
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) EduSpace/2.0'}
|
|
)
|
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
return resp.read().decode('utf-8', errors='replace')
|
|
|
|
def parse_sheet_rows(self, csv_content, sheet_name):
|
|
"""Parse raw CSV text into a structured list of class records"""
|
|
reader = csv.reader(io.StringIO(csv_content))
|
|
rows = list(reader)
|
|
|
|
header_idx = -1
|
|
col_offset = 0
|
|
|
|
for r_idx, row in enumerate(rows):
|
|
for c_idx, cell in enumerate(row):
|
|
cell_clean = cell.lower().strip()
|
|
if 'codigo' in cell_clean or 'código' in cell_clean:
|
|
header_idx = r_idx
|
|
col_offset = c_idx
|
|
break
|
|
if header_idx != -1:
|
|
break
|
|
|
|
if header_idx == -1:
|
|
return []
|
|
|
|
records = []
|
|
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', ' - ')
|
|
row_full_text = ' '.join(row).upper()
|
|
|
|
# Detect shift delimiters
|
|
if 'TURNO MAÑANA' in row_full_text or 'TURNO MANANA' in row_full_text:
|
|
current_shift = 'Mañana'
|
|
continue
|
|
elif 'TURNO VESPERTINO' in row_full_text or 'TURNO TARDE' in row_full_text or 'TURNO NOCHE' in row_full_text:
|
|
current_shift = 'Tarde'
|
|
continue
|
|
elif raw_cell.lower().startswith('turno'):
|
|
if 'mañ' in raw_cell.lower() or 'man' in raw_cell.lower():
|
|
current_shift = 'Mañana'
|
|
else:
|
|
current_shift = 'Tarde'
|
|
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', ' ')
|
|
|
|
# Check valid data row
|
|
if codigo and asignatura and not 'codigo' in codigo.lower():
|
|
# Double-check shift from start hour if possible
|
|
record_shift = current_shift
|
|
time_match = re.search(r'(\d{1,2}):(\d{2})', horario)
|
|
if time_match:
|
|
h = int(time_match.group(1))
|
|
if h >= 14:
|
|
record_shift = 'Tarde'
|
|
elif h < 13:
|
|
record_shift = 'Mañana'
|
|
|
|
records.append({
|
|
'dia': sheet_name,
|
|
'codigo': codigo,
|
|
'asignatura': asignatura,
|
|
'aula': aula,
|
|
'carrera': carrera,
|
|
'horario': horario,
|
|
'turno': record_shift
|
|
})
|
|
return records
|
|
|
|
def normalize_room(self, aula_raw):
|
|
"""
|
|
Normalize room string into (building, room_number, floor, capacity)
|
|
"""
|
|
aula_upper = aula_raw.upper().strip()
|
|
if not aula_upper or any(x in aula_upper for x in ['FERIADO', 'SIN ACTIVIDAD', 'CONSULTAR EN BEDELIA']):
|
|
return None
|
|
|
|
if 'VIRTUAL' in aula_upper:
|
|
return {
|
|
'building': 'Campus Virtual',
|
|
'code': 'VIRTUAL',
|
|
'floor': 0,
|
|
'capacity': 0,
|
|
'description': 'Espacio áulico virtual UniCABA (Capacidad Ilimitada ∞)'
|
|
}
|
|
|
|
if 'AUDITORIO' in aula_upper:
|
|
return {
|
|
'building': 'Edificio Central',
|
|
'code': 'Auditorio',
|
|
'floor': 0,
|
|
'capacity': 150,
|
|
'description': 'Auditorio principal UniCABA'
|
|
}
|
|
|
|
if 'SALA DE REUNI' in aula_upper:
|
|
floor = 2 if ('2DO' in aula_upper or '2°' in aula_upper) else 4
|
|
return {
|
|
'building': 'Edificio Central',
|
|
'code': f'Sala Reunión {floor}°P',
|
|
'floor': floor,
|
|
'capacity': 15,
|
|
'description': f'Sala de reunión piso {floor}'
|
|
}
|
|
|
|
# Standard classrooms: "Aula 402", "AULA 203", "306 - 406"
|
|
num_match = re.search(r'\d+', aula_raw)
|
|
if num_match:
|
|
num = num_match.group(0)
|
|
floor = int(num[0]) if len(num) >= 3 else 1
|
|
return {
|
|
'building': 'Edificio Central',
|
|
'code': f'Aula {num}',
|
|
'floor': floor,
|
|
'capacity': 35,
|
|
'description': f'Aula física {num} - Piso {floor}'
|
|
}
|
|
|
|
# Generic fallback
|
|
return {
|
|
'building': 'Edificio Central',
|
|
'code': aula_raw,
|
|
'floor': 1,
|
|
'capacity': 30,
|
|
'description': aula_raw
|
|
}
|
|
|
|
def parse_time_range(self, horario_str):
|
|
"""Parse '8:00 a 9:00hs' into (time(8, 0), time(9, 0))"""
|
|
m = TIME_REGEX.search(horario_str)
|
|
if not m:
|
|
return None, None
|
|
try:
|
|
h1 = int(m.group(1))
|
|
m1 = int(m.group(2) or 0)
|
|
h2 = int(m.group(3))
|
|
m2 = int(m.group(4) or 0)
|
|
return time(h1, m1), time(h2, m2)
|
|
except Exception:
|
|
return None, None
|
|
|
|
def sync(self):
|
|
"""
|
|
Main synchronization process:
|
|
1. Fetches all 6 day sheets.
|
|
2. Upserts Careers, Classrooms, Subjects, Commissions.
|
|
3. Generates Reservations in the calendar for the semester.
|
|
"""
|
|
stats = {
|
|
'total_rows_processed': 0,
|
|
'careers_created': 0,
|
|
'careers_updated': 0,
|
|
'classrooms_created': 0,
|
|
'classrooms_updated': 0,
|
|
'subjects_created': 0,
|
|
'subjects_updated': 0,
|
|
'commissions_created': 0,
|
|
'reservations_created': 0,
|
|
'errors': [],
|
|
'sample_records': []
|
|
}
|
|
|
|
all_raw_records = []
|
|
for sheet_cfg in SHEETS_CONFIG:
|
|
try:
|
|
csv_data = self.fetch_sheet_csv(sheet_cfg['gid'])
|
|
rows = self.parse_sheet_rows(csv_data, sheet_cfg['name'])
|
|
all_raw_records.extend([(r, sheet_cfg['day_idx']) for r in rows])
|
|
except Exception as e:
|
|
stats['errors'].append(f"Error al descargar o procesar la hoja de {sheet_cfg['name']}: {str(e)}")
|
|
|
|
stats['total_rows_processed'] = len(all_raw_records)
|
|
if not all_raw_records:
|
|
return stats
|
|
|
|
# Default admin user for reservations
|
|
default_user = User.query.filter_by(role='ADMIN').first() or User.query.first()
|
|
default_user_id = default_user.id if default_user else 1
|
|
|
|
# Ensure institutional buildings exist with official address
|
|
from app.models.building import Building
|
|
b_central = Building.query.filter_by(name='Edificio Central').first()
|
|
if not b_central:
|
|
b_central = Building(
|
|
name='Edificio Central',
|
|
code='CENTRAL',
|
|
address='Tte. Gral. Juan Domingo Perón 802, CABA',
|
|
floors=4,
|
|
description='Sede central institucional de UniCABA. Aulas de grado, auditorio y laboratorios.'
|
|
)
|
|
db.session.add(b_central)
|
|
db.session.flush()
|
|
else:
|
|
b_central.address = 'Tte. Gral. Juan Domingo Perón 802, CABA'
|
|
|
|
b_virtual = Building.query.filter_by(name='Campus Virtual').first()
|
|
if not b_virtual:
|
|
b_virtual = Building(
|
|
name='Campus Virtual',
|
|
code='VIRTUAL',
|
|
address='Entorno Digital / Microsoft Teams - Moodle',
|
|
floors=0,
|
|
description='Entorno de aulas virtuales y clases a distancia'
|
|
)
|
|
db.session.add(b_virtual)
|
|
db.session.flush()
|
|
|
|
buildings_cache = {b.name.strip().upper(): b for b in Building.query.all()}
|
|
careers_cache = {c.name.strip().upper(): c for c in Career.query.all()}
|
|
classrooms_cache = {(c.building.strip().upper(), c.code.strip().upper()): c for c in Classroom.query.all()}
|
|
subjects_cache = {s.code.strip().upper(): s for s in Subject.query.all()}
|
|
|
|
# Calculate current semester dates (anchored on academic year 2026)
|
|
# Week starting Monday 2026-08-31 to Saturday 2026-09-05
|
|
base_monday = date(2026, 8, 31)
|
|
weeks_to_generate = 4 # Generate reservations for 4 consecutive weeks
|
|
|
|
for rec, day_idx in all_raw_records:
|
|
try:
|
|
codigo = rec['codigo']
|
|
asignatura = rec['asignatura']
|
|
aula_raw = rec['aula']
|
|
carrera_name = rec['carrera'].strip()
|
|
horario_raw = rec['horario']
|
|
dia = rec['dia']
|
|
turno = rec.get('turno', 'Mañana')
|
|
|
|
# 1. Upsert Career
|
|
carrera_key = carrera_name.upper()
|
|
career = careers_cache.get(carrera_key)
|
|
if not career:
|
|
career = Career(name=carrera_name)
|
|
db.session.add(career)
|
|
db.session.flush()
|
|
careers_cache[carrera_key] = career
|
|
stats['careers_created'] += 1
|
|
|
|
# 2. Upsert Classroom
|
|
room_info = self.normalize_room(aula_raw)
|
|
classroom = None
|
|
if room_info:
|
|
room_key = (room_info['building'].upper(), room_info['code'].upper())
|
|
classroom = classrooms_cache.get(room_key)
|
|
b_entity = buildings_cache.get(room_info['building'].upper())
|
|
if not classroom:
|
|
classroom = Classroom(
|
|
building=room_info['building'],
|
|
building_id=b_entity.id if b_entity else None,
|
|
floor=room_info['floor'],
|
|
capacity=room_info['capacity'],
|
|
description=room_info['description'],
|
|
is_active=True
|
|
)
|
|
classroom.code = room_info['code']
|
|
db.session.add(classroom)
|
|
db.session.flush()
|
|
classrooms_cache[room_key] = classroom
|
|
stats['classrooms_created'] += 1
|
|
else:
|
|
classroom.capacity = max(classroom.capacity, room_info['capacity'])
|
|
if not classroom.building_id and b_entity:
|
|
classroom.building_id = b_entity.id
|
|
|
|
# 3. Upsert Subject
|
|
subject_key = codigo.upper()
|
|
subject = subjects_cache.get(subject_key)
|
|
if not subject:
|
|
subject = Subject(
|
|
code=codigo,
|
|
name=asignatura,
|
|
career_id=career.id,
|
|
department=career.name,
|
|
credits=4,
|
|
is_active=True
|
|
)
|
|
db.session.add(subject)
|
|
db.session.flush()
|
|
subjects_cache[subject_key] = subject
|
|
stats['subjects_created'] += 1
|
|
else:
|
|
if subject.name != asignatura:
|
|
subject.name = asignatura
|
|
stats['subjects_updated'] += 1
|
|
subject.career_id = career.id
|
|
subject.department = career.name
|
|
|
|
# 4. Upsert Commission
|
|
# Identify commission subcode e.g. from code suffix or default 'C1'
|
|
comm_code = 'C1'
|
|
if '-E' in codigo:
|
|
comm_code = codigo.split('-')[-1]
|
|
|
|
commission = Commission.query.filter_by(
|
|
subject_id=subject.id,
|
|
code=comm_code,
|
|
semester='2026-2',
|
|
year=2026
|
|
).first()
|
|
|
|
if not commission:
|
|
commission = Commission(
|
|
subject_id=subject.id,
|
|
code=comm_code,
|
|
semester='2026-2',
|
|
year=2026,
|
|
teacher_id=default_user_id,
|
|
max_students=classroom.capacity if classroom else 35,
|
|
current_students=0,
|
|
schedule=f"{dia} {horario_raw}",
|
|
shift=turno,
|
|
active=True
|
|
)
|
|
db.session.add(commission)
|
|
db.session.flush()
|
|
stats['commissions_created'] += 1
|
|
else:
|
|
commission.schedule = f"{dia} {horario_raw}"
|
|
commission.shift = turno
|
|
|
|
# 5. Generate Calendar Reservations
|
|
t_start, t_end = self.parse_time_range(horario_raw)
|
|
if classroom and t_start and t_end and day_idx is not None:
|
|
for w in range(weeks_to_generate):
|
|
class_date = base_monday + timedelta(days=day_idx, weeks=w)
|
|
dt_start = datetime.combine(class_date, t_start)
|
|
dt_end = datetime.combine(class_date, t_end)
|
|
|
|
existing_res = Reservation.query.filter_by(
|
|
classroom_id=classroom.id,
|
|
start_time=dt_start,
|
|
end_time=dt_end
|
|
).first()
|
|
|
|
if not existing_res:
|
|
res = Reservation(
|
|
classroom_id=classroom.id,
|
|
commission_id=commission.id,
|
|
user_id=default_user_id,
|
|
start_time=dt_start,
|
|
end_time=dt_end,
|
|
purpose=f"{subject.code} - {subject.name}",
|
|
status='CONFIRMED',
|
|
expected_attendees=min(classroom.capacity, 30),
|
|
shift=turno,
|
|
notes=f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
|
)
|
|
db.session.add(res)
|
|
stats['reservations_created'] += 1
|
|
else:
|
|
existing_res.shift = turno
|
|
existing_res.notes = f"Clase semanal {dia} ({turno}) - Carrera: {career.name}"
|
|
|
|
if len(stats['sample_records']) < 10:
|
|
stats['sample_records'].append({
|
|
'dia': dia,
|
|
'codigo': codigo,
|
|
'asignatura': asignatura,
|
|
'carrera': career.name,
|
|
'aula': classroom.code_display if classroom else aula_raw,
|
|
'horario': horario_raw
|
|
})
|
|
|
|
except Exception as row_err:
|
|
stats['errors'].append(f"Error en fila '{rec.get('codigo', '?')}': {str(row_err)}")
|
|
|
|
db.session.commit()
|
|
return stats
|