Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
@@ -10,12 +10,70 @@ import os
|
|
10 |
import matplotlib
|
11 |
import shutil
|
12 |
import colorsys
|
|
|
13 |
matplotlib.use('Agg')
|
14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
15 |
def extrair_tabelas_pdf(pdf_path):
|
16 |
"""Extrai tabelas do PDF e retorna um DataFrame processado."""
|
17 |
try:
|
18 |
-
# Extrair tabelas do PDF usando o método 'lattice'
|
19 |
tables = camelot.read_pdf(pdf_path, pages='all', flavor='lattice')
|
20 |
print(f"Tabelas extraídas: {len(tables)}")
|
21 |
|
@@ -25,146 +83,159 @@ def extrair_tabelas_pdf(pdf_path):
|
|
25 |
# Processar a primeira tabela
|
26 |
df = tables[0].df
|
27 |
|
28 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
if df.empty:
|
30 |
raise ValueError("A tabela extraída está vazia.")
|
31 |
-
|
32 |
-
#
|
33 |
-
|
34 |
-
|
35 |
-
csv_path = os.path.join(temp_dir, f'boletim_extraido_{i+1}.csv')
|
36 |
-
table.to_csv(csv_path)
|
37 |
-
print(f"Tabela {i+1} salva como CSV em {csv_path}")
|
38 |
|
39 |
return df
|
|
|
40 |
except Exception as e:
|
41 |
print(f"Erro na extração das tabelas: {str(e)}")
|
42 |
raise
|
43 |
|
44 |
-
def converter_nota(valor):
|
45 |
-
"""Converte valor de nota para float, tratando casos especiais."""
|
46 |
-
if pd.isna(valor) or valor == '-' or valor == 'N' or valor == '' or valor == 'None':
|
47 |
-
return 0
|
48 |
-
try:
|
49 |
-
if isinstance(valor, str):
|
50 |
-
# Remover possíveis espaços e substituir vírgula por ponto
|
51 |
-
valor_limpo = valor.strip().replace(',', '.')
|
52 |
-
# Se depois de limpar ainda estiver vazio, retorna 0
|
53 |
-
if not valor_limpo:
|
54 |
-
return 0
|
55 |
-
return float(valor_limpo)
|
56 |
-
elif isinstance(valor, (int, float)):
|
57 |
-
return float(valor)
|
58 |
-
return 0
|
59 |
-
except:
|
60 |
-
return 0
|
61 |
-
|
62 |
def obter_disciplinas_validas(df):
|
63 |
-
"""Identifica disciplinas válidas no boletim."""
|
64 |
-
# Colunas de notas e frequências
|
65 |
colunas_notas = ['Nota B1', 'Nota B2', 'Nota B3', 'Nota B4']
|
66 |
colunas_freq = ['%Freq B1', '%Freq B2', '%Freq B3', '%Freq B4']
|
67 |
|
68 |
-
|
69 |
-
for col in colunas_notas:
|
70 |
-
if col in df.columns:
|
71 |
-
df[col] = df[col].apply(lambda x: converter_nota(x))
|
72 |
-
|
73 |
-
# Converter frequências, tratando valores inválidos
|
74 |
-
for col in colunas_freq:
|
75 |
-
if col in df.columns:
|
76 |
-
df[col] = df[col].replace('%', '', regex=True)
|
77 |
-
df[col] = df[col].apply(lambda x: converter_nota(x) if pd.notna(x) else 0)
|
78 |
|
79 |
-
# Identificar disciplinas que têm pelo menos uma nota ou frequência
|
80 |
-
disciplinas_validas = []
|
81 |
for _, row in df.iterrows():
|
82 |
disciplina = row['Disciplina']
|
83 |
if pd.isna(disciplina) or disciplina == '':
|
84 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
85 |
|
86 |
-
|
87 |
-
|
|
|
|
|
|
|
|
|
|
|
88 |
|
89 |
-
|
90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
91 |
|
92 |
-
return disciplinas_validas
|
93 |
-
|
94 |
def gerar_paleta_cores(n_cores):
|
95 |
"""Gera uma paleta de cores distintas para o número de disciplinas."""
|
96 |
cores_base = [
|
97 |
-
'#
|
98 |
-
'#
|
99 |
-
'#
|
100 |
]
|
101 |
|
102 |
if n_cores > len(cores_base):
|
103 |
-
HSV_tuples = [(x/n_cores, 0.
|
104 |
cores_extras = ['#%02x%02x%02x' % tuple(int(x*255) for x in colorsys.hsv_to_rgb(*hsv))
|
105 |
for hsv in HSV_tuples]
|
106 |
return cores_extras
|
107 |
|
108 |
return cores_base[:n_cores]
|
109 |
|
110 |
-
def plotar_evolucao_bimestres(
|
111 |
"""Plota gráfico de evolução das notas por bimestre."""
|
112 |
-
|
113 |
-
disciplinas_validas = obter_disciplinas_validas(df_filtrado)
|
114 |
-
n_disciplinas = len(disciplinas_validas)
|
115 |
|
116 |
if n_disciplinas == 0:
|
117 |
raise ValueError("Nenhuma disciplina válida encontrada para plotar.")
|
118 |
|
119 |
-
# Calcular tamanho da figura
|
120 |
-
|
121 |
-
plt.figure(figsize=(14, altura_figura))
|
122 |
|
123 |
cores = gerar_paleta_cores(n_disciplinas)
|
124 |
-
marcadores = ['o', 's', '^', 'D', 'v', '<', '>', 'p', 'h', '
|
125 |
-
estilos_linha = ['-', '--', '-.', ':', '-', '--', '-.', ':', '-', '--'
|
126 |
|
127 |
plt.grid(True, linestyle='--', alpha=0.3, zorder=0)
|
128 |
|
129 |
-
|
130 |
-
|
131 |
-
|
132 |
-
|
133 |
-
if
|
134 |
-
|
135 |
-
|
136 |
-
notas_validas = pd.to_numeric(notas, errors='coerce').replace([np.nan, 0], 0) > 0
|
137 |
|
138 |
-
if notas_validas
|
139 |
-
|
140 |
-
notas_filtradas = pd.to_numeric(notas[notas_validas], errors='coerce').replace(np.nan, 0)
|
141 |
-
|
142 |
-
plt.plot(bimestres, notas_filtradas,
|
143 |
color=cores[idx % len(cores)],
|
144 |
marker=marcadores[idx % len(marcadores)],
|
145 |
markersize=8,
|
146 |
linewidth=2,
|
147 |
-
label=disciplina,
|
148 |
linestyle=estilos_linha[idx % len(estilos_linha)],
|
149 |
alpha=0.8)
|
150 |
|
151 |
-
for x, y in zip(bimestres,
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
plt.
|
160 |
-
|
161 |
-
plt.
|
162 |
-
plt.
|
163 |
-
|
164 |
-
|
165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
166 |
else:
|
167 |
-
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
|
168 |
|
169 |
plt.tight_layout()
|
170 |
|
@@ -173,67 +244,75 @@ def plotar_evolucao_bimestres(df_filtrado, temp_dir):
|
|
173 |
plt.close()
|
174 |
return plot_path
|
175 |
|
176 |
-
def plotar_graficos_destacados(
|
177 |
"""Plota gráficos de médias e frequências com destaques."""
|
178 |
-
|
179 |
-
disciplinas_validas = obter_disciplinas_validas(df_boletim_clean)
|
180 |
|
181 |
-
if not
|
182 |
raise ValueError("Nenhuma disciplina válida encontrada no boletim.")
|
183 |
|
184 |
-
|
185 |
-
|
186 |
-
# Calcular tamanho da figura baseado no número de disciplinas
|
187 |
-
altura_figura = max(6, n_disciplinas * 0.4)
|
188 |
-
plt.figure(figsize=(14, altura_figura))
|
189 |
-
|
190 |
-
df_filtrado = df_boletim_clean[df_boletim_clean['Disciplina'].isin(disciplinas_validas)]
|
191 |
-
disciplinas = df_filtrado['Disciplina'].astype(str)
|
192 |
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
medias_frequencia = freq_data.replace([np.nan, 0], 0).mean(axis=1)
|
197 |
-
|
198 |
-
# Processar notas
|
199 |
-
colunas_notas = ['Nota B1', 'Nota B2', 'Nota B3', 'Nota B4']
|
200 |
-
notas_data = df_filtrado[colunas_notas].astype(float)
|
201 |
-
medias_notas = notas_data.replace([np.nan, 0], 0).mean(axis=1)
|
202 |
|
203 |
-
cores_notas = ['red' if media <
|
204 |
-
|
|
|
|
|
205 |
|
206 |
-
|
|
|
207 |
|
208 |
-
|
|
|
209 |
barras_notas = plt.bar(disciplinas, medias_notas, color=cores_notas)
|
210 |
-
plt.title('Média de Notas por Disciplina
|
211 |
-
plt.xticks(rotation=45, ha='right')
|
212 |
-
plt.ylim(0,
|
213 |
|
|
|
|
|
|
|
|
|
|
|
|
|
214 |
for barra in barras_notas:
|
215 |
altura = barra.get_height()
|
216 |
plt.text(barra.get_x() + barra.get_width()/2., altura,
|
217 |
f'{altura:.1f}',
|
218 |
-
ha='center', va='bottom')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
219 |
|
220 |
-
|
221 |
-
|
222 |
-
plt.
|
223 |
-
|
224 |
-
plt.ylim(0, 100)
|
225 |
|
|
|
226 |
for barra in barras_freq:
|
227 |
altura = barra.get_height()
|
228 |
plt.text(barra.get_x() + barra.get_width()/2., altura,
|
229 |
f'{altura:.1f}%',
|
230 |
-
ha='center', va='bottom')
|
231 |
|
232 |
-
plt.suptitle(
|
|
|
|
|
|
|
233 |
|
234 |
-
if
|
235 |
-
plt.figtext(0.5, 0.02,
|
236 |
-
|
|
|
237 |
|
238 |
plt.tight_layout()
|
239 |
|
@@ -242,57 +321,95 @@ def plotar_graficos_destacados(df_boletim_clean, temp_dir):
|
|
242 |
plt.close()
|
243 |
return plot_path
|
244 |
|
245 |
-
def gerar_relatorio_pdf(df, grafico1_path, grafico2_path):
|
246 |
"""Gera relatório PDF com os gráficos e análises."""
|
247 |
pdf = FPDF()
|
|
|
248 |
pdf.add_page()
|
249 |
|
|
|
250 |
pdf.set_font('Helvetica', 'B', 16)
|
251 |
pdf.cell(0, 10, 'Relatório de Desempenho Escolar', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
|
252 |
-
pdf.ln(
|
253 |
|
254 |
-
|
255 |
-
|
256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
258 |
pdf.image(grafico1_path, x=10, w=190)
|
259 |
pdf.ln(10)
|
260 |
pdf.image(grafico2_path, x=10, w=190)
|
261 |
pdf.ln(10)
|
262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
263 |
pdf.set_font('Helvetica', 'B', 12)
|
264 |
pdf.cell(0, 10, 'Avisos Importantes:', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
265 |
-
pdf.
|
266 |
|
267 |
-
|
268 |
-
df_filtrado = df[df['Disciplina'].isin(disciplinas_validas)]
|
269 |
-
|
270 |
-
# Calcular médias
|
271 |
-
colunas_notas = ['Nota B1', 'Nota B2', 'Nota B3', 'Nota B4']
|
272 |
-
notas_data = df_filtrado[colunas_notas].astype(float)
|
273 |
-
medias_notas = notas_data.replace([np.nan, 0], 0).mean(axis=1)
|
274 |
|
275 |
-
#
|
276 |
-
|
277 |
-
|
278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
279 |
|
280 |
-
|
281 |
-
|
282 |
-
|
|
|
|
|
|
|
283 |
|
284 |
-
pdf.cell(0, 10, f'Média Global: {media_global:.1f}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
285 |
-
pdf.cell(0, 10, f'Frequência Global: {freq_global:.1f}%', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
286 |
pdf.ln(5)
|
287 |
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
pdf.cell(0, 10, f'- {disciplina}: Frequência abaixo de 75% ({media_freq:.1f}%)', 0,
|
294 |
-
new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
295 |
|
|
|
296 |
temp_pdf = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
|
297 |
pdf_path = temp_pdf.name
|
298 |
pdf.output(pdf_path)
|
@@ -302,31 +419,25 @@ def processar_boletim(file):
|
|
302 |
"""Função principal que processa o boletim e gera o relatório."""
|
303 |
temp_dir = None
|
304 |
try:
|
305 |
-
# Verificar se o arquivo é válido
|
306 |
if file is None:
|
307 |
return None, "Nenhum arquivo foi fornecido."
|
308 |
|
309 |
-
# Criar diretório temporário
|
310 |
temp_dir = tempfile.mkdtemp()
|
311 |
print(f"Diretório temporário criado: {temp_dir}")
|
312 |
|
313 |
-
# Verificar se o arquivo tem conteúdo
|
314 |
if not hasattr(file, 'name') or not os.path.exists(file.name):
|
315 |
return None, "Arquivo inválido ou corrompido."
|
316 |
|
317 |
if os.path.getsize(file.name) == 0:
|
318 |
return None, "O arquivo está vazio."
|
319 |
|
320 |
-
# Copiar o arquivo para o diretório temporário
|
321 |
temp_pdf = os.path.join(temp_dir, 'boletim.pdf')
|
322 |
shutil.copy2(file.name, temp_pdf)
|
323 |
print(f"PDF copiado para: {temp_pdf}")
|
324 |
|
325 |
-
# Verificar se a cópia foi bem sucedida
|
326 |
if not os.path.exists(temp_pdf) or os.path.getsize(temp_pdf) == 0:
|
327 |
return None, "Erro ao copiar o arquivo."
|
328 |
|
329 |
-
# Extrair tabelas do PDF
|
330 |
print("Iniciando extração das tabelas...")
|
331 |
df = extrair_tabelas_pdf(temp_pdf)
|
332 |
print("Tabelas extraídas com sucesso")
|
@@ -334,47 +445,38 @@ def processar_boletim(file):
|
|
334 |
if df is None or df.empty:
|
335 |
return None, "Não foi possível extrair dados do PDF."
|
336 |
|
337 |
-
# Renomear colunas para o formato esperado
|
338 |
try:
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
print("Gerando relatório PDF...")
|
362 |
-
pdf_path = gerar_relatorio_pdf(df, grafico1_path, grafico2_path)
|
363 |
-
print("Relatório PDF gerado")
|
364 |
-
|
365 |
-
# Criar arquivo temporário para retorno
|
366 |
-
output_file = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
|
367 |
-
output_path = output_file.name
|
368 |
-
shutil.copy2(pdf_path, output_path)
|
369 |
|
370 |
-
|
|
|
371 |
|
372 |
except Exception as e:
|
373 |
print(f"Erro durante o processamento: {str(e)}")
|
374 |
return None, f"Erro ao processar o boletim: {str(e)}"
|
375 |
|
376 |
finally:
|
377 |
-
# Limpar arquivos temporários
|
378 |
if temp_dir and os.path.exists(temp_dir):
|
379 |
try:
|
380 |
shutil.rmtree(temp_dir)
|
|
|
10 |
import matplotlib
|
11 |
import shutil
|
12 |
import colorsys
|
13 |
+
from datetime import datetime
|
14 |
matplotlib.use('Agg')
|
15 |
|
16 |
+
# Configurações globais
|
17 |
+
ESCALA_MAXIMA_NOTAS = 12 # Aumentado para melhor visualização
|
18 |
+
LIMITE_APROVACAO_NOTA = 5
|
19 |
+
LIMITE_APROVACAO_FREQ = 75
|
20 |
+
BIMESTRES = ['1º Bimestre', '2º Bimestre', '3º Bimestre', '4º Bimestre']
|
21 |
+
CONCEITOS_VALIDOS = ['ES', 'EP', 'ET'] # Conceitos não numéricos válidos
|
22 |
+
|
23 |
+
def converter_nota(valor):
|
24 |
+
"""Converte valor de nota para float, tratando casos especiais e conceitos."""
|
25 |
+
if pd.isna(valor) or valor == '-' or valor == 'N' or valor == '' or valor == 'None':
|
26 |
+
return None
|
27 |
+
|
28 |
+
# Se for string, limpar e verificar se é conceito
|
29 |
+
if isinstance(valor, str):
|
30 |
+
valor_limpo = valor.strip().upper()
|
31 |
+
if valor_limpo in CONCEITOS_VALIDOS:
|
32 |
+
# Converter conceitos para valores numéricos
|
33 |
+
conceitos_map = {'ET': 10, 'ES': 8, 'EP': 6}
|
34 |
+
return conceitos_map.get(valor_limpo)
|
35 |
+
|
36 |
+
# Tentar converter para número
|
37 |
+
try:
|
38 |
+
return float(valor_limpo.replace(',', '.'))
|
39 |
+
except:
|
40 |
+
return None
|
41 |
+
|
42 |
+
# Se for número, retornar diretamente
|
43 |
+
if isinstance(valor, (int, float)):
|
44 |
+
return float(valor)
|
45 |
+
|
46 |
+
return None
|
47 |
+
|
48 |
+
def calcular_media_bimestres(notas):
|
49 |
+
"""Calcula média considerando apenas bimestres com notas válidas."""
|
50 |
+
notas_validas = [nota for nota in notas if nota is not None]
|
51 |
+
if not notas_validas:
|
52 |
+
return 0
|
53 |
+
return sum(notas_validas) / len(notas_validas)
|
54 |
+
|
55 |
+
def calcular_frequencia_media(frequencias):
|
56 |
+
"""Calcula média de frequência considerando apenas bimestres cursados."""
|
57 |
+
freq_validas = []
|
58 |
+
for freq in frequencias:
|
59 |
+
try:
|
60 |
+
# Limpar string e converter para número
|
61 |
+
if isinstance(freq, str):
|
62 |
+
freq = freq.strip().replace('%', '').replace(',', '.')
|
63 |
+
if freq and freq != '-':
|
64 |
+
valor = float(freq)
|
65 |
+
if valor > 0: # Considerar apenas frequências positivas
|
66 |
+
freq_validas.append(valor)
|
67 |
+
except:
|
68 |
+
continue
|
69 |
+
|
70 |
+
if not freq_validas:
|
71 |
+
return 0
|
72 |
+
return sum(freq_validas) / len(freq_validas)
|
73 |
+
|
74 |
def extrair_tabelas_pdf(pdf_path):
|
75 |
"""Extrai tabelas do PDF e retorna um DataFrame processado."""
|
76 |
try:
|
|
|
77 |
tables = camelot.read_pdf(pdf_path, pages='all', flavor='lattice')
|
78 |
print(f"Tabelas extraídas: {len(tables)}")
|
79 |
|
|
|
83 |
# Processar a primeira tabela
|
84 |
df = tables[0].df
|
85 |
|
86 |
+
# Extrair nome do aluno e outras informações se disponível
|
87 |
+
info_aluno = {}
|
88 |
+
for i, row in df.iterrows():
|
89 |
+
if 'Nome do Aluno' in str(row[0]):
|
90 |
+
info_aluno['nome'] = row[1].strip() if len(row) > 1 else ''
|
91 |
+
elif 'RA' in str(row[0]):
|
92 |
+
info_aluno['ra'] = row[1].strip() if len(row) > 1 else ''
|
93 |
+
elif 'Escola' in str(row[0]):
|
94 |
+
info_aluno['escola'] = row[1].strip() if len(row) > 1 else ''
|
95 |
+
elif 'Turma' in str(row[0]):
|
96 |
+
info_aluno['turma'] = row[1].strip() if len(row) > 1 else ''
|
97 |
+
|
98 |
+
# Encontrar a tabela de notas
|
99 |
+
for i, table in enumerate(tables):
|
100 |
+
df_temp = table.df
|
101 |
+
if 'Disciplina' in str(df_temp.iloc[0,0]) or 'Bimestre' in str(df_temp.iloc[0,1]):
|
102 |
+
df = df_temp
|
103 |
+
break
|
104 |
+
|
105 |
if df.empty:
|
106 |
raise ValueError("A tabela extraída está vazia.")
|
107 |
+
|
108 |
+
# Adicionar informações do aluno ao DataFrame
|
109 |
+
for key, value in info_aluno.items():
|
110 |
+
df.attrs[key] = value
|
|
|
|
|
|
|
111 |
|
112 |
return df
|
113 |
+
|
114 |
except Exception as e:
|
115 |
print(f"Erro na extração das tabelas: {str(e)}")
|
116 |
raise
|
117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
118 |
def obter_disciplinas_validas(df):
|
119 |
+
"""Identifica disciplinas válidas no boletim com seus dados."""
|
|
|
120 |
colunas_notas = ['Nota B1', 'Nota B2', 'Nota B3', 'Nota B4']
|
121 |
colunas_freq = ['%Freq B1', '%Freq B2', '%Freq B3', '%Freq B4']
|
122 |
|
123 |
+
disciplinas_dados = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
124 |
|
|
|
|
|
125 |
for _, row in df.iterrows():
|
126 |
disciplina = row['Disciplina']
|
127 |
if pd.isna(disciplina) or disciplina == '':
|
128 |
continue
|
129 |
+
|
130 |
+
# Coletar notas e frequências
|
131 |
+
notas = []
|
132 |
+
freqs = []
|
133 |
+
bimestres_cursados = []
|
134 |
+
|
135 |
+
for i, (col_nota, col_freq) in enumerate(zip(colunas_notas, colunas_freq), 1):
|
136 |
+
nota = converter_nota(row[col_nota])
|
137 |
+
freq = row[col_freq] if col_freq in row else None
|
138 |
|
139 |
+
if nota is not None or (freq and freq != '-'):
|
140 |
+
bimestres_cursados.append(i)
|
141 |
+
notas.append(nota if nota is not None else 0)
|
142 |
+
freqs.append(freq)
|
143 |
+
else:
|
144 |
+
notas.append(None)
|
145 |
+
freqs.append(None)
|
146 |
|
147 |
+
# Calcular médias apenas se houver dados válidos
|
148 |
+
if bimestres_cursados:
|
149 |
+
media_notas = calcular_media_bimestres(notas)
|
150 |
+
media_freq = calcular_frequencia_media(freqs)
|
151 |
+
|
152 |
+
disciplinas_dados.append({
|
153 |
+
'disciplina': disciplina,
|
154 |
+
'notas': notas,
|
155 |
+
'frequencias': freqs,
|
156 |
+
'media_notas': media_notas,
|
157 |
+
'media_freq': media_freq,
|
158 |
+
'bimestres_cursados': bimestres_cursados
|
159 |
+
})
|
160 |
+
|
161 |
+
return disciplinas_dados
|
162 |
|
|
|
|
|
163 |
def gerar_paleta_cores(n_cores):
|
164 |
"""Gera uma paleta de cores distintas para o número de disciplinas."""
|
165 |
cores_base = [
|
166 |
+
'#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd',
|
167 |
+
'#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf',
|
168 |
+
'#393b79', '#637939', '#8c6d31', '#843c39', '#7b4173'
|
169 |
]
|
170 |
|
171 |
if n_cores > len(cores_base):
|
172 |
+
HSV_tuples = [(x/n_cores, 0.7, 0.85) for x in range(n_cores)]
|
173 |
cores_extras = ['#%02x%02x%02x' % tuple(int(x*255) for x in colorsys.hsv_to_rgb(*hsv))
|
174 |
for hsv in HSV_tuples]
|
175 |
return cores_extras
|
176 |
|
177 |
return cores_base[:n_cores]
|
178 |
|
179 |
+
def plotar_evolucao_bimestres(disciplinas_dados, temp_dir):
|
180 |
"""Plota gráfico de evolução das notas por bimestre."""
|
181 |
+
n_disciplinas = len(disciplinas_dados)
|
|
|
|
|
182 |
|
183 |
if n_disciplinas == 0:
|
184 |
raise ValueError("Nenhuma disciplina válida encontrada para plotar.")
|
185 |
|
186 |
+
# Calcular tamanho da figura para A4 (proporção 1:√2)
|
187 |
+
plt.figure(figsize=(11.69, 8.27)) # Tamanho A4 em polegadas
|
|
|
188 |
|
189 |
cores = gerar_paleta_cores(n_disciplinas)
|
190 |
+
marcadores = ['o', 's', '^', 'D', 'v', '<', '>', 'p', 'h', '*']
|
191 |
+
estilos_linha = ['-', '--', '-.', ':', '-', '--', '-.', ':', '-', '--']
|
192 |
|
193 |
plt.grid(True, linestyle='--', alpha=0.3, zorder=0)
|
194 |
|
195 |
+
for idx, disc_data in enumerate(disciplinas_dados):
|
196 |
+
notas = pd.Series(disc_data['notas'])
|
197 |
+
bimestres_cursados = disc_data['bimestres_cursados']
|
198 |
+
|
199 |
+
if bimestres_cursados:
|
200 |
+
notas_validas = [nota for i, nota in enumerate(notas, 1) if i in bimestres_cursados and nota is not None]
|
201 |
+
bimestres = [bim for bim in bimestres_cursados if notas[bim-1] is not None]
|
|
|
202 |
|
203 |
+
if notas_validas:
|
204 |
+
plt.plot(bimestres, notas_validas,
|
|
|
|
|
|
|
205 |
color=cores[idx % len(cores)],
|
206 |
marker=marcadores[idx % len(marcadores)],
|
207 |
markersize=8,
|
208 |
linewidth=2,
|
209 |
+
label=disc_data['disciplina'],
|
210 |
linestyle=estilos_linha[idx % len(estilos_linha)],
|
211 |
alpha=0.8)
|
212 |
|
213 |
+
for x, y in zip(bimestres, notas_validas):
|
214 |
+
if y is not None:
|
215 |
+
plt.annotate(f"{y:.1f}", (x, y),
|
216 |
+
textcoords="offset points",
|
217 |
+
xytext=(0, 5),
|
218 |
+
ha='center',
|
219 |
+
fontsize=8)
|
220 |
+
|
221 |
+
plt.title('Evolução das Médias por Disciplina ao Longo dos Bimestres',
|
222 |
+
pad=20, fontsize=12, fontweight='bold')
|
223 |
+
plt.xlabel('Bimestres', fontsize=10)
|
224 |
+
plt.ylabel('Notas', fontsize=10)
|
225 |
+
plt.xticks([1, 2, 3, 4], ['1º Bim', '2º Bim', '3º Bim', '4º Bim'])
|
226 |
+
plt.ylim(0, ESCALA_MAXIMA_NOTAS)
|
227 |
+
|
228 |
+
# Adicionar linha de aprovação
|
229 |
+
plt.axhline(y=LIMITE_APROVACAO_NOTA, color='r', linestyle='--', alpha=0.3)
|
230 |
+
plt.text(0.02, LIMITE_APROVACAO_NOTA + 0.1, 'Média mínima para aprovação',
|
231 |
+
transform=plt.gca().get_yaxis_transform(), color='r', alpha=0.5)
|
232 |
+
|
233 |
+
# Ajustar legenda baseado no número de disciplinas
|
234 |
+
if n_disciplinas > 8:
|
235 |
+
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=8,
|
236 |
+
ncol=max(1, n_disciplinas // 12))
|
237 |
else:
|
238 |
+
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left', ncol=1)
|
239 |
|
240 |
plt.tight_layout()
|
241 |
|
|
|
244 |
plt.close()
|
245 |
return plot_path
|
246 |
|
247 |
+
def plotar_graficos_destacados(disciplinas_dados, temp_dir):
|
248 |
"""Plota gráficos de médias e frequências com destaques."""
|
249 |
+
n_disciplinas = len(disciplinas_dados)
|
|
|
250 |
|
251 |
+
if not n_disciplinas:
|
252 |
raise ValueError("Nenhuma disciplina válida encontrada no boletim.")
|
253 |
|
254 |
+
# Calcular tamanho da figura para A4 (proporção 1:√2)
|
255 |
+
plt.figure(figsize=(11.69, 8.27)) # Tamanho A4 em polegadas
|
|
|
|
|
|
|
|
|
|
|
|
|
256 |
|
257 |
+
disciplinas = [d['disciplina'] for d in disciplinas_dados]
|
258 |
+
medias_notas = [d['media_notas'] for d in disciplinas_dados]
|
259 |
+
medias_freq = [d['media_freq'] for d in disciplinas_dados]
|
|
|
|
|
|
|
|
|
|
|
|
|
260 |
|
261 |
+
cores_notas = ['red' if media < LIMITE_APROVACAO_NOTA else 'green'
|
262 |
+
for media in medias_notas]
|
263 |
+
cores_freq = ['red' if media < LIMITE_APROVACAO_FREQ else 'green'
|
264 |
+
for media in medias_freq]
|
265 |
|
266 |
+
media_global = np.mean(medias_notas)
|
267 |
+
freq_global = np.mean(medias_freq)
|
268 |
|
269 |
+
# Gráfico de notas
|
270 |
+
plt.subplot(2, 1, 1)
|
271 |
barras_notas = plt.bar(disciplinas, medias_notas, color=cores_notas)
|
272 |
+
plt.title('Média de Notas por Disciplina', pad=20, fontsize=12, fontweight='bold')
|
273 |
+
plt.xticks(rotation=45, ha='right', fontsize=8)
|
274 |
+
plt.ylim(0, ESCALA_MAXIMA_NOTAS)
|
275 |
|
276 |
+
# Adicionar linha de média mínima
|
277 |
+
plt.axhline(y=LIMITE_APROVACAO_NOTA, color='r', linestyle='--', alpha=0.3)
|
278 |
+
plt.text(0.02, LIMITE_APROVACAO_NOTA + 0.1, 'Média mínima',
|
279 |
+
transform=plt.gca().get_yaxis_transform(), color='r', alpha=0.5)
|
280 |
+
|
281 |
+
# Valores nas barras
|
282 |
for barra in barras_notas:
|
283 |
altura = barra.get_height()
|
284 |
plt.text(barra.get_x() + barra.get_width()/2., altura,
|
285 |
f'{altura:.1f}',
|
286 |
+
ha='center', va='bottom', fontsize=8)
|
287 |
+
|
288 |
+
# Gráfico de frequências
|
289 |
+
plt.subplot(2, 1, 2)
|
290 |
+
barras_freq = plt.bar(disciplinas, medias_freq, color=cores_freq)
|
291 |
+
plt.title('Frequência Média por Disciplina', pad=20, fontsize=12, fontweight='bold')
|
292 |
+
plt.xticks(rotation=45, ha='right', fontsize=8)
|
293 |
+
plt.ylim(0, 110) # Deixar espaço para os valores acima das barras
|
294 |
|
295 |
+
# Adicionar linha de frequência mínima
|
296 |
+
plt.axhline(y=LIMITE_APROVACAO_FREQ, color='r', linestyle='--', alpha=0.3)
|
297 |
+
plt.text(0.02, LIMITE_APROVACAO_FREQ + 1, 'Frequência mínima',
|
298 |
+
transform=plt.gca().get_yaxis_transform(), color='r', alpha=0.5)
|
|
|
299 |
|
300 |
+
# Valores nas barras
|
301 |
for barra in barras_freq:
|
302 |
altura = barra.get_height()
|
303 |
plt.text(barra.get_x() + barra.get_width()/2., altura,
|
304 |
f'{altura:.1f}%',
|
305 |
+
ha='center', va='bottom', fontsize=8)
|
306 |
|
307 |
+
plt.suptitle(
|
308 |
+
f'Média Global: {media_global:.1f} | Frequência Global: {freq_global:.1f}%',
|
309 |
+
y=0.95, fontsize=12, fontweight='bold'
|
310 |
+
)
|
311 |
|
312 |
+
if freq_global < LIMITE_APROVACAO_FREQ:
|
313 |
+
plt.figtext(0.5, 0.02,
|
314 |
+
"Atenção: Risco de Reprovação por Baixa Frequência",
|
315 |
+
ha="center", fontsize=10, color="red")
|
316 |
|
317 |
plt.tight_layout()
|
318 |
|
|
|
321 |
plt.close()
|
322 |
return plot_path
|
323 |
|
324 |
+
def gerar_relatorio_pdf(df, disciplinas_dados, grafico1_path, grafico2_path):
|
325 |
"""Gera relatório PDF com os gráficos e análises."""
|
326 |
pdf = FPDF()
|
327 |
+
pdf.set_auto_page_break(auto=True, margin=15)
|
328 |
pdf.add_page()
|
329 |
|
330 |
+
# Cabeçalho
|
331 |
pdf.set_font('Helvetica', 'B', 16)
|
332 |
pdf.cell(0, 10, 'Relatório de Desempenho Escolar', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
|
333 |
+
pdf.ln(5)
|
334 |
|
335 |
+
# Informações do aluno
|
336 |
+
pdf.set_font('Helvetica', '', 11)
|
337 |
+
if hasattr(df, 'attrs'):
|
338 |
+
if 'nome' in df.attrs:
|
339 |
+
pdf.cell(0, 7, f'Aluno: {df.attrs["nome"]}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
340 |
+
if 'ra' in df.attrs:
|
341 |
+
pdf.cell(0, 7, f'RA: {df.attrs["ra"]}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
342 |
+
if 'escola' in df.attrs:
|
343 |
+
pdf.cell(0, 7, f'Escola: {df.attrs["escola"]}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
344 |
+
if 'turma' in df.attrs:
|
345 |
+
pdf.cell(0, 7, f'Turma: {df.attrs["turma"]}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
346 |
|
347 |
+
pdf.ln(5)
|
348 |
+
|
349 |
+
# Data do relatório
|
350 |
+
data_atual = datetime.now().strftime('%d/%m/%Y')
|
351 |
+
pdf.set_font('Helvetica', 'I', 10)
|
352 |
+
pdf.cell(0, 5, f'Relatório gerado em: {data_atual}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R')
|
353 |
+
pdf.ln(10)
|
354 |
+
|
355 |
+
# Gráficos
|
356 |
pdf.image(grafico1_path, x=10, w=190)
|
357 |
pdf.ln(10)
|
358 |
pdf.image(grafico2_path, x=10, w=190)
|
359 |
pdf.ln(10)
|
360 |
|
361 |
+
# Seção de Análise
|
362 |
+
pdf.set_font('Helvetica', 'B', 14)
|
363 |
+
pdf.cell(0, 10, 'Análise de Desempenho', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
364 |
+
pdf.ln(5)
|
365 |
+
|
366 |
+
# Calcular médias globais
|
367 |
+
medias_notas = [d['media_notas'] for d in disciplinas_dados]
|
368 |
+
medias_freq = [d['media_freq'] for d in disciplinas_dados]
|
369 |
+
media_global = np.mean(medias_notas)
|
370 |
+
freq_global = np.mean(medias_freq)
|
371 |
+
|
372 |
+
# Resumo geral
|
373 |
+
pdf.set_font('Helvetica', '', 11)
|
374 |
+
pdf.cell(0, 7, f'Média Global: {media_global:.1f}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
375 |
+
pdf.cell(0, 7, f'Frequência Global: {freq_global:.1f}%', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
376 |
+
pdf.ln(5)
|
377 |
+
|
378 |
+
# Avisos Importantes
|
379 |
pdf.set_font('Helvetica', 'B', 12)
|
380 |
pdf.cell(0, 10, 'Avisos Importantes:', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
381 |
+
pdf.ln(2)
|
382 |
|
383 |
+
pdf.set_font('Helvetica', '', 10)
|
|
|
|
|
|
|
|
|
|
|
|
|
384 |
|
385 |
+
# Disciplinas com baixo desempenho
|
386 |
+
disciplinas_risco = []
|
387 |
+
for disc_data in disciplinas_dados:
|
388 |
+
avisos = []
|
389 |
+
if disc_data['media_notas'] < LIMITE_APROVACAO_NOTA:
|
390 |
+
avisos.append(f"Média de notas abaixo de {LIMITE_APROVACAO_NOTA} ({disc_data['media_notas']:.1f})")
|
391 |
+
if disc_data['media_freq'] < LIMITE_APROVACAO_FREQ:
|
392 |
+
avisos.append(f"Frequência abaixo de {LIMITE_APROVACAO_FREQ}% ({disc_data['media_freq']:.1f}%)")
|
393 |
+
|
394 |
+
if avisos:
|
395 |
+
disciplinas_risco.append((disc_data['disciplina'], avisos))
|
396 |
|
397 |
+
if disciplinas_risco:
|
398 |
+
for disc, avisos in disciplinas_risco:
|
399 |
+
for aviso in avisos:
|
400 |
+
pdf.cell(0, 7, f'- {disc}: {aviso}', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
401 |
+
else:
|
402 |
+
pdf.cell(0, 7, 'Nenhum problema identificado.', 0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='L')
|
403 |
|
|
|
|
|
404 |
pdf.ln(5)
|
405 |
|
406 |
+
# Rodapé
|
407 |
+
pdf.set_y(-30)
|
408 |
+
pdf.set_font('Helvetica', 'I', 8)
|
409 |
+
pdf.cell(0, 10, 'Este relatório é uma análise automática e deve ser validado junto à secretaria da escola.',
|
410 |
+
0, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='C')
|
|
|
|
|
411 |
|
412 |
+
# Salvar PDF
|
413 |
temp_pdf = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
|
414 |
pdf_path = temp_pdf.name
|
415 |
pdf.output(pdf_path)
|
|
|
419 |
"""Função principal que processa o boletim e gera o relatório."""
|
420 |
temp_dir = None
|
421 |
try:
|
|
|
422 |
if file is None:
|
423 |
return None, "Nenhum arquivo foi fornecido."
|
424 |
|
|
|
425 |
temp_dir = tempfile.mkdtemp()
|
426 |
print(f"Diretório temporário criado: {temp_dir}")
|
427 |
|
|
|
428 |
if not hasattr(file, 'name') or not os.path.exists(file.name):
|
429 |
return None, "Arquivo inválido ou corrompido."
|
430 |
|
431 |
if os.path.getsize(file.name) == 0:
|
432 |
return None, "O arquivo está vazio."
|
433 |
|
|
|
434 |
temp_pdf = os.path.join(temp_dir, 'boletim.pdf')
|
435 |
shutil.copy2(file.name, temp_pdf)
|
436 |
print(f"PDF copiado para: {temp_pdf}")
|
437 |
|
|
|
438 |
if not os.path.exists(temp_pdf) or os.path.getsize(temp_pdf) == 0:
|
439 |
return None, "Erro ao copiar o arquivo."
|
440 |
|
|
|
441 |
print("Iniciando extração das tabelas...")
|
442 |
df = extrair_tabelas_pdf(temp_pdf)
|
443 |
print("Tabelas extraídas com sucesso")
|
|
|
445 |
if df is None or df.empty:
|
446 |
return None, "Não foi possível extrair dados do PDF."
|
447 |
|
|
|
448 |
try:
|
449 |
+
# Processar disciplinas
|
450 |
+
disciplinas_dados = obter_disciplinas_validas(df)
|
451 |
+
if not disciplinas_dados:
|
452 |
+
return None, "Nenhuma disciplina válida encontrada no boletim."
|
453 |
+
|
454 |
+
# Gerar gráficos
|
455 |
+
print("Gerando gráficos...")
|
456 |
+
grafico1_path = plotar_evolucao_bimestres(disciplinas_dados, temp_dir)
|
457 |
+
grafico2_path = plotar_graficos_destacados(disciplinas_dados, temp_dir)
|
458 |
+
print("Gráficos gerados")
|
459 |
+
|
460 |
+
# Gerar PDF
|
461 |
+
print("Gerando relatório PDF...")
|
462 |
+
pdf_path = gerar_relatorio_pdf(df, disciplinas_dados, grafico1_path, grafico2_path)
|
463 |
+
print("Relatório PDF gerado")
|
464 |
+
|
465 |
+
# Criar arquivo de retorno
|
466 |
+
output_file = tempfile.NamedTemporaryFile(delete=False, suffix='.pdf')
|
467 |
+
output_path = output_file.name
|
468 |
+
shutil.copy2(pdf_path, output_path)
|
469 |
+
|
470 |
+
return output_path, "Relatório gerado com sucesso!"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
471 |
|
472 |
+
except Exception as e:
|
473 |
+
return None, f"Erro ao processar os dados: {str(e)}"
|
474 |
|
475 |
except Exception as e:
|
476 |
print(f"Erro durante o processamento: {str(e)}")
|
477 |
return None, f"Erro ao processar o boletim: {str(e)}"
|
478 |
|
479 |
finally:
|
|
|
480 |
if temp_dir and os.path.exists(temp_dir):
|
481 |
try:
|
482 |
shutil.rmtree(temp_dir)
|