From 8bf37953a000daa4ca9557ace9005a9ad39f3653 Mon Sep 17 00:00:00 2001 From: Diego Cirilo Date: Wed, 3 Dec 2025 16:21:45 -0300 Subject: [PATCH 1/3] readme --- README.md | 53 ++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7298f6f..0cc636c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,51 @@ +Presente! + # Presente! -O **Presente!** é um sistema open source para gerenciamento de frequência em atividades extra-classe no IFRN. -Professores e gestores podem cadastrar atividades ou eventos, e os participantes registram sua presença por meio de QR Codes ou códigos em texto. +Sistema open source para gerenciamento de presença em atividades extra-classe do IFRN. Professores e gestores cadastram atividades, e os participantes registram presença através de QR Codes dinâmicos. -O sistema utiliza o **SUAP** para autenticação e identificação automática das turmas, permitindo a geração de relatórios de frequência de forma integrada. +Utiliza o **SUAP** para autenticação institucional e identificação dos participantes, com geração de relatórios integrada. --- ## Funcionalidades -* Registro de atividades extra-classe autorizadas pelo professor. -* Registro de presença pelos próprios participantes, por meio de QR-Code. -* Validação automática ou manual da presença pelo administrador. -* Mecanismos para prevenção de fraudes: - * QR-Code/código de verificação dinâmico com tempo de atualização configurável; - * Registro de IP e *timestamp* no momento do registro; - * Possibilidade de restringir o acesso à rede local do campus. -* Autenticação via SUAP, com identificação automática dos participantes. -* Geração de relatórios de frequência e participação com filtros por curso, turma, turno, etc. -* Interface simples e objetiva. +### Atividades +* CRUD completo com suporte a múltiplos responsáveis +* Controle automático de status (não iniciada, ativa, encerrada) +* Página pública com QR Code para registro de presença +* Sincronização de tempo com o servidor +* Recarregamento automático da interface ao término + +### Presença +* Registro via QR Code dinâmico ou link direto +* QR Codes com renovação configurável (padrão: 30 segundos) +* Tokens com validação de expiração +* Histórico completo por atividade e por usuário +* Contadores em tempo real + +### Segurança +* Restrição por endereço IP ou faixa de rede (CIDR) +* Gerenciamento de redes permitidas (superusuários) +* Registro de IP e timestamp em cada marcação +* Sincronização de relógio cliente-servidor para prevenir manipulação + +### Relatórios +* Filtros por nome, tipo de usuário, curso e período +* Seleção de colunas para impressão +* Ordenação customizável +* Formato otimizado para impressão física + +### Interface +* Design responsivo (Bootstrap/AdminLTE) +* Atualizações via HTMX (sem reload completo) +* Contadores regressivos em tempo real +* Feedback visual de erros e sucessos + +### Administração +* Painel para superusuários +* Gerenciamento de usuários e redes +* Visualização de todas as atividades do sistema --- From c2106a515353d1ba264eedbe6151d54e8668ba3d Mon Sep 17 00:00:00 2001 From: Diego Cirilo Date: Wed, 3 Dec 2025 16:23:46 -0300 Subject: [PATCH 2/3] readme --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 0cc636c..0d2d35b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -Presente! - -# Presente! +# presente! presente! Sistema open source para gerenciamento de presença em atividades extra-classe do IFRN. Professores e gestores cadastram atividades, e os participantes registram presença através de QR Codes dinâmicos. From b217237d24cbab630246239afde92d4196466aa7 Mon Sep 17 00:00:00 2001 From: Diego Cirilo Date: Wed, 3 Dec 2025 17:03:00 -0300 Subject: [PATCH 3/3] exportando pdf (weasyprint) e csv --- presente/forms.py | 2 + presente/urls.py | 17 +- presente/views.py | 137 +++++++++++- requirements.txt | 11 + static/css/report.css | 207 ------------------ .../presente/activity_attendance_list.html | 4 +- ...endance_print.html => attendance_pdf.html} | 171 +++++++++++++-- ...ml => attendance_export_config_modal.html} | 37 +++- 8 files changed, 348 insertions(+), 238 deletions(-) delete mode 100644 static/css/report.css rename templates/presente/{attendance_print.html => attendance_pdf.html} (53%) rename templates/presente/includes/{attendance_print_config_modal.html => attendance_export_config_modal.html} (63%) diff --git a/presente/forms.py b/presente/forms.py index c125ecf..f8362f1 100644 --- a/presente/forms.py +++ b/presente/forms.py @@ -148,6 +148,8 @@ class AttendancePrintConfigForm(forms.Form): ("-type", _("Tipo (Z-A)")), ("curso", _("Curso (A-Z)")), ("-curso", _("Curso (Z-A)")), + ("periodo", _("Período (A-Z)")), + ("-periodo", _("Período (Z-A)")), ] columns = forms.MultipleChoiceField( diff --git a/presente/urls.py b/presente/urls.py index 0b506fd..2f0c5e7 100644 --- a/presente/urls.py +++ b/presente/urls.py @@ -30,14 +30,19 @@ name="activity_attendances", ), path( - "activity//attendances/print/config/", - views.ActivityAttendancePrintConfigView.as_view(), - name="activity_attendance_print_config", + "activity//attendances/export/config/", + views.ActivityAttendanceExportConfigView.as_view(), + name="activity_attendance_export_config", ), path( - "activity//attendances/print/", - views.ActivityAttendancePrintView.as_view(), - name="activity_attendance_print", + "activity//attendances/pdf/", + views.ActivityAttendancePDFView.as_view(), + name="activity_attendance_pdf", + ), + path( + "activity//attendances/csv/", + views.ActivityAttendanceCSVExportView.as_view(), + name="activity_attendance_csv", ), path( "activity//attendance//delete/", diff --git a/presente/views.py b/presente/views.py index 43b8d4b..9c8d98a 100644 --- a/presente/views.py +++ b/presente/views.py @@ -7,6 +7,7 @@ from django.http import Http404 from django.urls import reverse, reverse_lazy from django_filters.views import FilterView +from django_weasyprint import WeasyTemplateResponseMixin from core.mixins import PageTitleMixin, SuperuserRequiredMixin from core.views import ( CoreListView, @@ -20,6 +21,10 @@ import qrcode.image.svg from io import BytesIO import base64 +import os +import csv +from django.conf import settings +from django.http import HttpResponse from .models import Activity, Attendance, Network from .tables import ( ActivityTable, @@ -363,12 +368,12 @@ def get_success_url(self): ) -class ActivityAttendancePrintConfigView( +class ActivityAttendanceExportConfigView( ActivityOwnerMixin, LoginRequiredMixin, TemplateView, ): - template_name = "presente/includes/attendance_print_config_modal.html" + template_name = "presente/includes/attendance_export_config_modal.html" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -376,6 +381,10 @@ def get_context_data(self, **kwargs): context["activity"] = activity context["form"] = AttendancePrintConfigForm() + # Get current sort setting from page + current_sort = self.request.GET.get("sort_by", "name") + context["current_sort"] = current_sort + # Convert GET params to dict for easier template iteration filter_params = {} for key, value in self.request.GET.items(): @@ -386,15 +395,24 @@ def get_context_data(self, **kwargs): return context -class ActivityAttendancePrintView( +class ActivityAttendancePDFView( + WeasyTemplateResponseMixin, ActivityOwnerMixin, LoginRequiredMixin, FilterView, ): model = Attendance filterset_class = ActivityAttendanceFilter - template_name = "presente/attendance_print.html" + template_name = "presente/attendance_pdf.html" context_object_name = "attendances" + pdf_filename = "relatorio_presencas.pdf" + + def get_pdf_filename(self): + activity = self.get_activity() + safe_title = "".join( + c if c.isalnum() or c in (" ", "-", "_") else "" for c in activity.title + ).replace(" ", "_") + return f"presencas_{safe_title}.pdf" def get_queryset(self): activity = self.get_activity() @@ -413,6 +431,8 @@ def get_queryset(self): "-type": "-user__type", "curso": "user__curso", "-curso": "-user__curso", + "periodo": "user__periodo_referencia", + "-periodo": "-user__periodo_referencia", "checked_in_at": "checked_in_at", "-checked_in_at": "-checked_in_at", } @@ -458,9 +478,118 @@ def get_context_data(self, **kwargs): context["filter_info"] = filter_info context["generated_at"] = timezone.now() + # Add absolute path to logo for WeasyPrint + logo_path = os.path.join( + settings.BASE_DIR, "static", "img", "presente-icon.svg" + ) + context["logo_path"] = logo_path + return context +class ActivityAttendanceCSVExportView( + ActivityOwnerMixin, + LoginRequiredMixin, + FilterView, +): + model = Attendance + filterset_class = ActivityAttendanceFilter + + def get_queryset(self): + activity = self.get_activity() + qs = Attendance.objects.filter(activity=activity).select_related("user") + + # Apply sorting + sort_by = self.request.GET.get("sort_by", "name") + sort_mapping = { + "name": "user__full_name", + "-name": "-user__full_name", + "type": "user__type", + "-type": "-user__type", + "curso": "user__curso", + "-curso": "-user__curso", + "periodo": "user__periodo_referencia", + "-periodo": "-user__periodo_referencia", + "checked_in_at": "checked_in_at", + "-checked_in_at": "-checked_in_at", + } + sort_field = sort_mapping.get(sort_by, "user__full_name") + qs = qs.order_by(sort_field) + + return qs + + def get(self, request, *args, **kwargs): + activity = self.get_activity() + filterset = self.filterset_class(request.GET, queryset=self.get_queryset()) + queryset = filterset.qs + + # Get column configuration + columns = request.GET.getlist("columns") + if not columns: + columns = ["number", "name", "matricula", "checked_in_at"] + + # Generate filename + safe_title = "".join( + c if c.isalnum() or c in (" ", "-", "_") else "" for c in activity.title + ).replace(" ", "_") + filename = f"presencas_{safe_title}.csv" + + # Create CSV response + response = HttpResponse(content_type="text/csv; charset=utf-8-sig") + response["Content-Disposition"] = f'attachment; filename="{filename}"' + + writer = csv.writer(response) + + # Write header row + header = [] + if "number" in columns: + header.append("#") + if "name" in columns: + header.append("Nome") + if "email" in columns: + header.append("Email") + if "matricula" in columns: + header.append("Matrícula") + if "type" in columns: + header.append("Tipo") + if "curso" in columns: + header.append("Curso") + if "periodo" in columns: + header.append("Período") + if "checked_in_at" in columns: + header.append("Registrado em") + if "ip_address" in columns: + header.append("Rede") + + writer.writerow(header) + + # Write data rows + for idx, attendance in enumerate(queryset, 1): + row = [] + if "number" in columns: + row.append(idx) + if "name" in columns: + row.append(attendance.user.get_full_name()) + if "email" in columns: + row.append(attendance.user.email or "-") + if "matricula" in columns: + row.append(attendance.user.matricula or "-") + if "type" in columns: + row.append(attendance.user.get_type_display()) + if "curso" in columns: + row.append(attendance.user.curso or "-") + if "periodo" in columns: + row.append(attendance.user.periodo_referencia or "-") + if "checked_in_at" in columns: + row.append(attendance.checked_in_at.strftime("%d/%m/%Y %H:%M:%S")) + if "ip_address" in columns: + row.append(attendance.get_network_name()) + + writer.writerow(row) + + return response + + # Network CRUD Views diff --git a/requirements.txt b/requirements.txt index d6c0021..6235bc9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,12 @@ asgiref==3.9.2 +brotli==1.2.0 certifi==2025.8.3 cffi==2.0.0 cfgv==3.4.0 charset-normalizer==3.4.3 crispy-bootstrap5==2025.6 cryptography==46.0.2 +cssselect2==0.8.0 distlib==0.4.0 Django==5.2.7 django-allauth==65.11.2 @@ -16,7 +18,9 @@ django-simple-menu==2.1.4 django-tables2==2.7.5 django-taggit==6.1.0 django-tinymce==4.1.0 +django-weasyprint==2.4.0 filelock==3.19.1 +fonttools==4.61.0 identify==2.6.15 idna==3.10 nodeenv==1.9.1 @@ -25,12 +29,19 @@ pillow==12.0.0 platformdirs==4.4.0 pre_commit==4.3.0 pycparser==2.23 +pydyf==0.12.1 PyJWT==2.10.1 +pyphen==0.17.2 python-dotenv==1.1.1 PyYAML==6.0.3 qrcode==8.2 requests==2.32.5 ruff==0.13.3 sqlparse==0.5.3 +tinycss2==1.5.1 +tinyhtml5==2.0.0 urllib3==2.5.0 virtualenv==20.34.0 +weasyprint==67.0 +webencodings==0.5.1 +zopfli==0.4.0 diff --git a/static/css/report.css b/static/css/report.css deleted file mode 100644 index fe10e76..0000000 --- a/static/css/report.css +++ /dev/null @@ -1,207 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: Arial, sans-serif; - font-size: 12pt; - line-height: 1.4; - color: #000; - background: #fff; - padding: 20mm; -} - -.header { - margin-bottom: 20px; - padding-bottom: 15px; - border-bottom: 2px solid #333; -} - -.header-brand { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 15px; -} - -.brand-logo { - width: 40px; - height: 40px; -} - -.brand-name { - font-size: 20pt; - font-weight: bold; - color: #667eea; -} - -.header h1 { - font-size: 18pt; - margin-bottom: 10px; - color: #000; -} - -.header .activity-title { - font-size: 14pt; - font-weight: bold; - margin-bottom: 8px; -} - -.meta-info { - font-size: 10pt; - color: #333; - margin-bottom: 5px; -} - -.filter-info { - background: #f5f5f5; - padding: 10px; - margin: 15px 0; - border-left: 3px solid #666; -} - -.filter-info h3 { - font-size: 11pt; - margin-bottom: 5px; - color: #333; -} - -.filter-info ul { - list-style: none; - font-size: 10pt; -} - -.filter-info li { - margin-bottom: 3px; -} - -.stats { - margin: 15px 0; - padding: 10px; - background: #f9f9f9; - border: 1px solid #ddd; - font-size: 11pt; -} - -.stats strong { - font-weight: bold; -} - -table { - width: 100%; - border-collapse: collapse; - margin-top: 15px; - page-break-inside: auto; -} - -thead { - display: table-header-group; -} - -tr { - page-break-inside: avoid; - page-break-after: auto; -} - -th { - background: #333; - color: #fff; - padding: 8px; - text-align: left; - font-size: 11pt; - font-weight: bold; - border: 1px solid #000; -} - -td { - padding: 6px 8px; - border: 1px solid #ccc; - font-size: 10pt; -} - -tbody tr:nth-child(even) { - background: #f9f9f9; -} - -.no-results { - text-align: center; - padding: 40px; - color: #666; - font-style: italic; -} - -.footer { - margin-top: 30px; - padding-top: 15px; - border-top: 1px solid #ccc; - font-size: 9pt; - color: #666; - text-align: center; -} - -.footer-brand { - margin-top: 8px; - font-weight: bold; - color: #667eea; -} - -/* Print-specific styles */ -@media print { - body { - padding: 10mm; - } - - .no-print { - display: none !important; - } - - .brand-name, - .footer-brand { - color: #000; - } - - table { - page-break-inside: auto; - } - - tr { - page-break-inside: avoid; - page-break-after: auto; - } - - thead { - display: table-header-group; - } - - @page { - margin: 15mm; - } -} - -/* Button for printing */ -.print-button { - position: fixed; - top: 20px; - right: 20px; - padding: 12px 24px; - background: #007bff; - color: white; - border: none; - border-radius: 5px; - font-size: 14px; - cursor: pointer; - box-shadow: 0 2px 5px rgba(0,0,0,0.2); - z-index: 1000; -} - -.print-button:hover { - background: #0056b3; -} - -@media print { - .print-button { - display: none; - } -} diff --git a/templates/presente/activity_attendance_list.html b/templates/presente/activity_attendance_list.html index 2e31582..182e79f 100644 --- a/templates/presente/activity_attendance_list.html +++ b/templates/presente/activity_attendance_list.html @@ -2,11 +2,11 @@ {% block extra_actions %} {% endblock %} diff --git a/templates/presente/attendance_print.html b/templates/presente/attendance_pdf.html similarity index 53% rename from templates/presente/attendance_print.html rename to templates/presente/attendance_pdf.html index 5000f09..3489d7d 100644 --- a/templates/presente/attendance_print.html +++ b/templates/presente/attendance_pdf.html @@ -3,18 +3,168 @@ - Relatório de Presenças - {{ activity.title }} - + - -
- + presente!

Relatório de Presenças

@@ -87,7 +237,7 @@

Filtros Aplicados:

{% endif %} {% endif %} - {% if "checked_in_at" in columns %}{{ attendance.checked_in_at|date:"d/m/Y H:i" }}{% endif %} + {% if "checked_in_at" in columns %}{{ attendance.checked_in_at|date:"d/m/Y H:i:s" }}{% endif %} {% if "ip_address" in columns %}{{ attendance.get_network_name }}{% endif %} {% endfor %} @@ -98,10 +248,5 @@

Filtros Aplicados:

Nenhuma presença encontrada com os filtros aplicados.
{% endif %} - - diff --git a/templates/presente/includes/attendance_print_config_modal.html b/templates/presente/includes/attendance_export_config_modal.html similarity index 63% rename from templates/presente/includes/attendance_print_config_modal.html rename to templates/presente/includes/attendance_export_config_modal.html index bbf2f94..6d10582 100644 --- a/templates/presente/includes/attendance_print_config_modal.html +++ b/templates/presente/includes/attendance_export_config_modal.html @@ -2,12 +2,12 @@