From 2ee55dfe62f7e1f84f43ca1b95b368fa2ae86f89 Mon Sep 17 00:00:00 2001 From: Diego Cirilo Date: Sun, 30 Nov 2025 00:15:33 -0300 Subject: [PATCH 1/2] refactor --- core/middleware.py | 70 ------ core/mixins.py | 16 +- core/views.py | 15 +- presente/filters.py | 34 +++ presente/forms.py | 5 +- presente/menus.py | 6 +- ..._enabled_alter_activity_owners_and_more.py | 30 +++ presente/models.py | 64 ++---- presente/tables.py | 24 +- presente/urls.py | 6 +- presente/views.py | 136 +++++------- static/css/presente.css | 56 ++++- static/css/report.css | 207 ++++++++++++++++++ static/img/favicon.ico | Bin 4286 -> 15406 bytes static/img/placeholder/cover.png | Bin 22267 -> 0 bytes static/img/placeholder/promo.png | Bin 17661 -> 0 bytes static/img/presente.svg | 88 -------- static/img/site.webmanifest | 1 + static/js/qrcode.js | 152 +++++++++++++ templates/403.html | 2 +- templates/404.html | 2 +- templates/500.html | 2 +- templates/presente/activity_detail.html | 9 +- templates/presente/attendance_print.html | 189 +--------------- templates/presente/checkin_done.html | 67 ++++++ templates/presente/checkin_error.html | 37 ---- templates/presente/checkin_success.html | 46 ---- templates/presente/includes/qr_code.html | 94 -------- templates/presente/includes/qr_content.html | 64 ++++++ templates/presente/includes/qr_error.html | 3 - templates/presente/includes/qr_expired.html | 5 - .../presente/includes/socialmedia_table.html | 20 -- templates/presente/ip_restricted.html | 55 ----- templates/presente/private_base.html | 4 +- templates/presente/public_activity.html | 16 +- 35 files changed, 766 insertions(+), 759 deletions(-) delete mode 100644 core/middleware.py create mode 100644 presente/migrations/0013_activity_is_enabled_alter_activity_owners_and_more.py create mode 100644 static/css/report.css delete mode 100644 static/img/placeholder/cover.png delete mode 100644 static/img/placeholder/promo.png delete mode 100644 static/img/presente.svg create mode 100644 static/img/site.webmanifest create mode 100644 static/js/qrcode.js create mode 100644 templates/presente/checkin_done.html delete mode 100644 templates/presente/checkin_error.html delete mode 100644 templates/presente/checkin_success.html delete mode 100644 templates/presente/includes/qr_code.html create mode 100644 templates/presente/includes/qr_content.html delete mode 100644 templates/presente/includes/qr_error.html delete mode 100644 templates/presente/includes/qr_expired.html delete mode 100644 templates/presente/includes/socialmedia_table.html delete mode 100644 templates/presente/ip_restricted.html diff --git a/core/middleware.py b/core/middleware.py deleted file mode 100644 index e82d713..0000000 --- a/core/middleware.py +++ /dev/null @@ -1,70 +0,0 @@ -from django.http import HttpResponseForbidden -from django.conf import settings -from django.template import loader -from ipaddress import ip_address, ip_network - - -class IPRestrictionMiddleware: - def __init__(self, get_response): - self.get_response = get_response - self.enabled = getattr(settings, "IP_RESTRICTION_ENABLED", False) - self.allowed_networks = getattr(settings, "ALLOWED_IP_NETWORKS", []) - self.exclude_paths = getattr(settings, "IP_RESTRICTION_EXCLUDE_PATHS", []) - - def __call__(self, request): - # Skip if IP restriction is disabled - if not self.enabled: - return self.get_response(request) - - # Skip if path is excluded - if any(request.path.startswith(path) for path in self.exclude_paths): - return self.get_response(request) - - # Get client IP address - client_ip = self.get_client_ip(request) - - # Check if IP is allowed - if not self.is_ip_allowed(client_ip): - # Return 403 Forbidden response - template = loader.get_template("403_ip_restricted.html") - context = {"client_ip": client_ip} - return HttpResponseForbidden(template.render(context, request)) - - response = self.get_response(request) - return response - - def get_client_ip(self, request): - x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") - if x_forwarded_for: - # X-Forwarded-For can contain multiple IPs, get the first one - ip = x_forwarded_for.split(",")[0].strip() - else: - ip = request.META.get("REMOTE_ADDR") - return ip - - def is_ip_allowed(self, client_ip): - if not self.allowed_networks: - # If no networks configured, deny all (safe default) - return False - - try: - client_addr = ip_address(client_ip) - - for network in self.allowed_networks: - try: - # Try to match as network (CIDR notation) - if "/" in network: - if client_addr in ip_network(network, strict=False): - return True - # Match as individual IP - else: - if client_addr == ip_address(network): - return True - except ValueError: - # Invalid network configuration, skip - continue - - return False - except ValueError: - # Invalid client IP, deny access - return False diff --git a/core/mixins.py b/core/mixins.py index 5044f50..781f112 100644 --- a/core/mixins.py +++ b/core/mixins.py @@ -1,12 +1,21 @@ from django.urls import reverse_lazy -from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.auth.mixins import PermissionRequiredMixin, AccessMixin from django.contrib.messages.views import SuccessMessageMixin from django.utils.translation import gettext_lazy as _ from django.views.generic.edit import CreateView, DeleteView -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, PermissionDenied from django.forms import inlineformset_factory +class SuperuserRequiredMixin(AccessMixin): + def dispatch(self, request, *args, **kwargs): + if not request.user.is_authenticated: + return self.handle_no_permission() + if not request.user.is_superuser: + raise PermissionDenied + return super().dispatch(request, *args, **kwargs) + + class PageTitleMixin: page_title = "" @@ -46,9 +55,10 @@ def get_context_data(self, **kwargs): class AutoPermissionRequiredMixin(PermissionRequiredMixin): permission_action = "view" + permission_required = None def get_permission_required(self): - if not self.permission_required: + if self.permission_required is None: model = getattr(self, "model", None) if model is None and hasattr(self, "get_queryset"): model = self.get_queryset().model diff --git a/core/views.py b/core/views.py index 33daa30..70a0c00 100644 --- a/core/views.py +++ b/core/views.py @@ -48,11 +48,22 @@ def get_fields(self): no_check = not isinstance(self.fields, (list, tuple)) for field in self.object._meta.fields: if no_check or field.name in self.fields: + value = getattr(self.object, field.name) + safe = field.name in self.safe_fields + + # Format boolean fields with badges + if field.get_internal_type() == "BooleanField": + if value: + value = ' Sim' + else: + value = ' Não' + safe = True + selected_fields.append( { "label": field.verbose_name, - "value": getattr(self.object, field.name), - "safe": True if field.name in self.safe_fields else False, + "value": value, + "safe": safe, } ) return selected_fields diff --git a/presente/filters.py b/presente/filters.py index 3cb6e87..41a2d64 100644 --- a/presente/filters.py +++ b/presente/filters.py @@ -9,11 +9,27 @@ class ActivityFilter(django_filters.FilterSet): + STATUS_CHOICES = [ + ("", "---------"), + ("active", _("Ativa")), + ("not_started", _("Não Iniciada")), + ("expired", _("Encerrada")), + ("not_enabled", _("Desabilitada")), + ] + title = django_filters.CharFilter( lookup_expr="icontains", label=_("Título"), widget=forms.TextInput(attrs={"class": "form-control"}), ) + status = django_filters.ChoiceFilter( + choices=STATUS_CHOICES, + label=_("Status"), + method="filter_status", + widget=forms.Select( + attrs={"class": "form-select", "data-tom-select": "simple"} + ), + ) tags = django_filters.ModelChoiceFilter( queryset=Tag.objects.all(), label=_("Tags"), @@ -35,10 +51,28 @@ class ActivityFilter(django_filters.FilterSet): widget=forms.DateInput(attrs={"class": "form-control", "type": "date"}), ) + def filter_status(self, queryset, name, value): + from django.utils import timezone + + now = timezone.now() + + if value == "active": + return queryset.filter( + start_time__lte=now, end_time__gte=now, is_enabled=True + ) + elif value == "not_started": + return queryset.filter(start_time__gt=now) + elif value == "expired": + return queryset.filter(end_time__lt=now) + elif value == "not_enabled": + return queryset.filter(is_enabled=False) + return queryset + class Meta: model = Activity fields = [ "title", + "status", "tags", "start_time__gte", "start_time__lte", diff --git a/presente/forms.py b/presente/forms.py index 5ca04b0..796ea8f 100644 --- a/presente/forms.py +++ b/presente/forms.py @@ -32,6 +32,7 @@ class Meta: "end_time", "qr_timeout", "restrict_ip", + "is_enabled", "allowed_networks", "owners", ] @@ -52,8 +53,9 @@ class Meta: format="%Y-%m-%dT%H:%M", ), "qr_timeout": forms.NumberInput( - attrs={"class": "form-control", "min": "10"} + attrs={"class": "form-control", "min": "0"} ), + "is_enabled": forms.CheckboxInput(attrs={"class": "form-check-input"}), "restrict_ip": forms.CheckboxInput(attrs={"class": "form-check-input"}), "allowed_networks": forms.CheckboxSelectMultiple( attrs={"class": "form-check-input"} @@ -93,6 +95,7 @@ def __init__(self, *args, **kwargs): "start_time", "end_time", "qr_timeout", + "is_enabled", "restrict_ip", "allowed_networks", "owners", diff --git a/presente/menus.py b/presente/menus.py index a4c5222..cbaacce 100644 --- a/presente/menus.py +++ b/presente/menus.py @@ -24,7 +24,7 @@ "presente", MenuItem( "Minhas Atividades", - reverse("presente:my_activities"), + reverse("presente:activity_list"), icon="bi bi-list-check", check=lambda r: r.user.has_perm("presente.view_activity"), ), @@ -36,7 +36,7 @@ "presente", MenuItem( "Atividades", - reverse("presente:activity_list"), + reverse("presente:admin_activities"), icon="bi bi-list-check", check=lambda r: r.user.is_superuser, ), @@ -48,6 +48,6 @@ "Usuários", reverse("users:user_list"), icon="bi bi-person", - check=lambda r: r.user.has_perm("users.view_user"), + check=lambda r: r.user.is_superuser, ), ) diff --git a/presente/migrations/0013_activity_is_enabled_alter_activity_owners_and_more.py b/presente/migrations/0013_activity_is_enabled_alter_activity_owners_and_more.py new file mode 100644 index 0000000..de0674f --- /dev/null +++ b/presente/migrations/0013_activity_is_enabled_alter_activity_owners_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.7 on 2025-11-29 23:45 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('presente', '0012_remove_is_published'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='is_enabled', + field=models.BooleanField(default=True, verbose_name='Habilitar?'), + ), + migrations.AlterField( + model_name='activity', + name='owners', + field=models.ManyToManyField(related_name='owned_activities', to=settings.AUTH_USER_MODEL, verbose_name='Responsáveis'), + ), + migrations.AlterField( + model_name='activity', + name='qr_timeout', + field=models.IntegerField(default=0, help_text='Tempo de validade de cada QR Code para registro de presença (em segundos). Use "0" para desabilitar.', verbose_name='Timeout do QR Code (segundos)'), + ), + ] diff --git a/presente/models.py b/presente/models.py index dc969bf..bf887b3 100644 --- a/presente/models.py +++ b/presente/models.py @@ -46,7 +46,6 @@ class Activity(models.Model): User, related_name="owned_activities", verbose_name=_("Responsáveis"), - blank=True, ) title = models.CharField(_("Título"), max_length=100) tags = TaggableManager( @@ -58,11 +57,16 @@ class Activity(models.Model): ) start_time = models.DateTimeField(_("Data/hora de início")) end_time = models.DateTimeField(_("Data/hora de término")) + is_enabled = models.BooleanField( + default=True, + verbose_name=_("Habilitar?"), + help_text=_("Habilitar o registro de presença."), + ) qr_timeout = models.IntegerField( _("Timeout do QR Code (segundos)"), - default=30, + default=0, help_text=_( - "Tempo de validade de cada QR Code para registro de presença (em segundos)" + 'Tempo de validade de cada QR Code para registro de presença (em segundos). Use "0" para desabilitar.' ), ) restrict_ip = models.BooleanField( @@ -80,45 +84,32 @@ class Activity(models.Model): def __str__(self): return self.title - def is_expired(self): - return timezone.now() > self.end_time - - def is_not_started(self): - return timezone.now() < self.start_time + @property + def status(self): + if timezone.now() > self.end_time: + return "expired" + elif timezone.now() < self.start_time: + return "not_started" + elif not self.is_enabled: + return "not_enabled" + else: + return "active" def is_ip_allowed(self, client_ip): - import logging - - logger = logging.getLogger(__name__) - - # If IP restriction is disabled, allow all if not self.restrict_ip: - logger.debug( - f"IP restriction disabled for activity {self.id}, allowing {client_ip}" - ) return True - # Get active networks for this activity active_networks = self.allowed_networks.filter(is_active=True) - # If no networks configured, deny all (when restriction is enabled) if not active_networks.exists(): - logger.warning( - f"IP restriction enabled but no active networks configured for activity {self.id}, denying {client_ip}" - ) return False from ipaddress import ip_address, ip_network try: client_addr = ip_address(client_ip) - logger.debug(f"Checking IP {client_ip} for activity {self.id}") - # Check against each configured network for network_config in active_networks: - logger.debug(f"Checking against network: {network_config.name}") - - # Parse IP addresses from the network config (one per line) ip_list = [ ip.strip() for ip in network_config.ip_addresses.split("\n") @@ -127,36 +118,19 @@ def is_ip_allowed(self, client_ip): for ip_entry in ip_list: try: - # Try to match as network (CIDR notation) if "/" in ip_entry: network_obj = ip_network(ip_entry, strict=False) if client_addr in network_obj: - logger.info( - f"IP {client_ip} matched network {ip_entry} in {network_config.name} for activity {self.id}" - ) return True - # Match as individual IP else: ip_addr = ip_address(ip_entry) if client_addr == ip_addr: - logger.info( - f"IP {client_ip} matched individual IP {ip_entry} in {network_config.name} for activity {self.id}" - ) return True - except ValueError as e: - # Invalid IP configuration, skip - logger.error( - f"Invalid IP '{ip_entry}' in network {network_config.name}: {e}" - ) + except ValueError: continue - logger.warning( - f"IP {client_ip} denied access to activity {self.id} - no matching networks" - ) return False - except ValueError as e: - # Invalid client IP, deny access - logger.error(f"Invalid client IP '{client_ip}': {e}") + except ValueError: return False class Meta: diff --git a/presente/tables.py b/presente/tables.py index cfeb784..cd339af 100644 --- a/presente/tables.py +++ b/presente/tables.py @@ -1,5 +1,6 @@ import django_tables2 from django.utils.translation import gettext_lazy as _ +from django.utils.safestring import mark_safe from .models import Activity, Attendance @@ -13,6 +14,11 @@ class CoreTable(django_tables2.Table): class ActivityTable(CoreTable): + status = django_tables2.Column( + verbose_name=_("Status"), + orderable=False, + empty_values=(), + ) tags_list = django_tables2.TemplateColumn( template_name="presente/includes/tags_column.html", verbose_name=_("Tags"), @@ -21,9 +27,18 @@ class ActivityTable(CoreTable): exclude_from_export=True, ) + def render_status(self, record): + status_map = { + "active": ' Ativa', + "not_started": ' Não Iniciada', + "expired": ' Encerrada', + "not_enabled": ' Desabilitada', + } + return mark_safe(status_map.get(record.status, "")) + class Meta: model = Activity - fields = ("title", "tags_list", "start_time", "end_time") + fields = ("title", "status", "tags_list", "start_time", "end_time") class AttendanceTable(django_tables2.Table): @@ -71,12 +86,8 @@ class ActivityAttendanceTable(django_tables2.Table): user_name = django_tables2.Column( accessor="user", verbose_name=_("Nome"), - orderable=False, - ) - user_email = django_tables2.Column( - accessor="user__email", - verbose_name=_("E-mail"), orderable=True, + order_by=("user__full_name",), ) user_type = django_tables2.Column( accessor="user__type", @@ -118,7 +129,6 @@ class Meta: model = Attendance fields = ( "user_name", - "user_email", "user_type", "user_curso", "user_periodo_referencia", diff --git a/presente/urls.py b/presente/urls.py index e8c0cf0..c54dbb5 100644 --- a/presente/urls.py +++ b/presente/urls.py @@ -4,7 +4,11 @@ app_name = "presente" urlpatterns = [ path("", views.IndexView.as_view(), name="index"), - path("my-activities/", views.MyActivitiesView.as_view(), name="my_activities"), + path( + "admin-activities/", + views.AdminActivitiesView.as_view(), + name="admin_activities", + ), path("activity/", views.ActivityListView.as_view(), name="activity_list"), path("activity/add", views.ActivityCreateView.as_view(), name="activity_add"), path( diff --git a/presente/views.py b/presente/views.py index 6776d11..732bef8 100644 --- a/presente/views.py +++ b/presente/views.py @@ -1,14 +1,13 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.views.generic.base import TemplateView -from django.views.generic import View from django.contrib.auth import get_user_model -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ from django.utils import timezone from django.http import Http404 from django.urls import reverse, reverse_lazy from django_filters.views import FilterView -from core.mixins import PageTitleMixin +from core.mixins import PageTitleMixin, SuperuserRequiredMixin from core.views import ( CoreCreateView, CoreDetailView, @@ -55,30 +54,23 @@ def get_context_data(self, **kwargs): return context -class MyActivitiesView(CoreFilterView): +class ActivityListView(CoreFilterView): page_title = _("Minhas Atividades") model = Activity table_class = ActivityTable filterset_class = ActivityFilter + permission_required = [] def get_queryset(self): - # Always filter by current user as owner, even for superusers return Activity.objects.filter(owners=self.request.user) -class ActivityListView(CoreFilterView): +class AdminActivitiesView(SuperuserRequiredMixin, CoreFilterView): page_title = _("Atividades") model = Activity table_class = ActivityTable filterset_class = ActivityFilter - def get_queryset(self): - # Only superusers should access this view (all activities) - if self.request.user.is_superuser: - return Activity.objects.all() - # Regular users shouldn't reach this view, but fallback to owned activities - return Activity.objects.filter(owners=self.request.user) - class ActivityCreateView(CoreCreateView): model = Activity @@ -120,7 +112,6 @@ def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["attendances"] = self.object.attendances.select_related("user").all() context["encoded_id"] = encode_activity_id(self.object.id) - context["is_expired"] = self.object.is_expired() return context @@ -150,131 +141,104 @@ def get_queryset(self): class PublicActivityView(TemplateView): template_name = "presente/public_activity.html" - def get(self, request, encoded_id): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + encoded_id = kwargs.get("encoded_id") + activity_id = decode_activity_id(encoded_id) if not activity_id: raise Http404("Activity not found") - activity = get_object_or_404(Activity, id=activity_id) + activity = get_object_or_404(Activity, id=activity_id, is_enabled=True) - # Default context and template - template = self.template_name - context = { - "activity": activity, - "encoded_id": encoded_id, - } + context["activity"] = activity + context["encoded_id"] = encoded_id - # Check for errors and update context/template if needed - if activity.is_expired(): - template = "presente/checkin_error.html" - context["error"] = _( - "Esta atividade já encerrou. Não é mais possível registrar presença." - ) - elif activity.is_not_started(): - template = "presente/checkin_error.html" - context["error"] = _( - "Esta atividade ainda não iniciou. Aguarde o horário de início." - ) + return context - return render(request, template, context) +class ActivityQRCodeView(TemplateView): + template_name = "presente/includes/qr_content.html" -class ActivityQRCodeView(View): - def get(self, request, encoded_id): - # Default template and context - template = "presente/includes/qr_error.html" - context = {} - status = 404 + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + encoded_id = kwargs.get("encoded_id") + + context["server_time"] = timezone.now().isoformat() + context["encoded_id"] = encoded_id activity_id = decode_activity_id(encoded_id) if activity_id: activity = get_object_or_404(Activity, id=activity_id) - status = 200 context["activity"] = activity - # Check if activity has expired - if activity.is_expired(): - template = "presente/includes/qr_expired.html" - else: - # Generate QR code + if activity.status == "active": checkin_token = generate_checkin_token(activity.id, activity.qr_timeout) checkin_path = reverse( "presente:checkin", kwargs={"token": checkin_token} ) - checkin_url = request.build_absolute_uri(checkin_path) + checkin_url = self.request.build_absolute_uri(checkin_path) - template = "presente/includes/qr_code.html" context.update( { "checkin_url": checkin_url, "timeout": activity.qr_timeout, - "encoded_id": encoded_id, } ) - return render(request, template, context, status=status) + return context -class CheckInView(LoginRequiredMixin, View): - def get(self, request, token): - # Default template and context - template = "presente/checkin_error.html" - context = {} +class CheckInView(LoginRequiredMixin, TemplateView): + template_name = "presente/checkin_done.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + token = kwargs.get("token") + context["success"] = False - activity_id = verify_checkin_token(token, 300) # Max 5 min for safety + activity_id = verify_checkin_token(token, 300) if not activity_id: - error_msg = _("QR Code inválido ou expirado.") - context["error"] = error_msg + context["error"] = _("QR Code inválido ou expirado.") else: activity = get_object_or_404(Activity, id=activity_id) - client_ip = get_client_ip(request) + client_ip = get_client_ip(self.request) - # Base context for all cases - context = { - "activity": activity, - "encoded_id": encode_activity_id(activity.id), - } + context["activity"] = activity + context["encoded_id"] = encode_activity_id(activity.id) - # Check IP restriction if not activity.is_ip_allowed(client_ip): - error_msg = _( + context["error"] = _( "Acesso negado. Seu IP ({ip}) não tem permissão para registrar presença nesta atividade." ).format(ip=client_ip) - context["error"] = error_msg context["client_ip"] = client_ip - # Check if activity hasn't started yet elif activity.is_not_started(): - error_msg = _( + context["error"] = _( "Esta atividade ainda não começou. Não é possível registrar presença." ) - context["error"] = error_msg - # Check if activity has expired elif activity.is_expired(): - error_msg = _( + context["error"] = _( "Esta atividade já encerrou. Não é mais possível registrar presença." ) - context["error"] = error_msg - # Verify token with activity's timeout elif not verify_checkin_token(token, activity.qr_timeout): - error_msg = _("QR Code expirado. Solicite um novo código.") - context["error"] = error_msg - # Success - register attendance + context["error"] = _("QR Code expirado. Solicite um novo código.") else: attendance, created = Attendance.objects.get_or_create( activity=activity, - user=request.user, + user=self.request.user, defaults={"ip_address": client_ip}, ) - template = "presente/checkin_success.html" - context = { - "activity": activity, - "attendance": attendance, - "created": created, - } + context.update( + { + "success": True, + "attendance": attendance, + "created": created, + } + ) - return render(request, template, context) + return context class MyAttendancesView(CoreFilterView): @@ -284,6 +248,7 @@ class MyAttendancesView(CoreFilterView): filterset_class = AttendanceFilter table_pagination = {"per_page": 20} actions = [] + permission_required = [] def get_queryset(self): return ( @@ -302,6 +267,7 @@ class ActivityAttendanceListView(ActivityOwnerMixin, CoreFilterView): table_pagination = {"per_page": 20} context_object_name = "attendances" actions = ["delete"] + permission_required = [] def get_page_title(self): activity = self.get_activity() diff --git a/static/css/presente.css b/static/css/presente.css index 754083c..b15440b 100644 --- a/static/css/presente.css +++ b/static/css/presente.css @@ -154,7 +154,9 @@ margin-bottom: 0.75rem; } .qr-wrapper { - display: inline-block; + display: flex; + flex-direction: column; + align-items: center; } .qr-code-link { display: block; @@ -201,6 +203,35 @@ max-width: 100%; height: auto; } +/* Datetime badge - compact utility display */ +.qr-datetime-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + background: rgba(102, 126, 234, 0.08); + border-radius: 0.5rem; + font-size: 0.8rem; + color: #555; + font-weight: 500; + font-variant-numeric: tabular-nums; + margin-bottom: 1rem; +} +.qr-datetime-badge i { + color: #667eea; + font-size: 0.75rem; +} +.qr-datetime-separator { + color: #999; + margin: 0 0.15rem; +} + +/* Status container */ +.qr-status-container { + display: flex; + flex-direction: column; + align-items: center; +} .qr-countdown { margin-top: 1rem; font-size: 0.95rem; @@ -278,6 +309,10 @@ .qr-code-link { padding: 1rem; } + .qr-datetime-badge { + font-size: 0.75rem; + padding: 0.35rem 0.65rem; + } } @media (max-width: 576px) { @@ -305,6 +340,14 @@ .qr-code-link { padding: 0.75rem; } + .qr-datetime-badge { + font-size: 0.72rem; + padding: 0.3rem 0.6rem; + gap: 0.35rem; + } + .qr-countdown { + font-size: 0.85rem; + } .footer-content { gap: 0.35rem; } @@ -356,6 +399,17 @@ .qr-code-link { padding: 0.75rem; } + .qr-datetime-badge { + font-size: 0.7rem; + padding: 0.3rem 0.55rem; + gap: 0.3rem; + } + .qr-datetime-badge i { + font-size: 0.7rem; + } + .qr-countdown { + font-size: 0.85rem; + } .page-footer { padding-top: 0.5rem; } diff --git a/static/css/report.css b/static/css/report.css new file mode 100644 index 0000000..fe10e76 --- /dev/null +++ b/static/css/report.css @@ -0,0 +1,207 @@ +* { + 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/static/img/favicon.ico b/static/img/favicon.ico index eaeae51bdad5cf33cb752c73462f0d9bea634c73..e5d67e9350685ff2b8db1d8681648f222a16084a 100644 GIT binary patch literal 15406 zcmeHOXK)l(65cEO-}$QSt8!i4*%lJn2rUuNQfjrVDb@)DB#FQ0+gk`{$`o=?Ci|!&g`Q5ah|Hx%)EK;_1FFS^?Uug zWitKA^p@$}cTIfvHw}H;WC}BxO#c4%_uB87Oz~V69Q^uwO_S;9`zBLuuER~Zg}t0_ zikHO}G{qWcE@~2AkM#VwO|d1e?o_@PDz$67rV(wvu$JZ?`c}PPmA1_j>eI11nS`sX zyo~y6YNy_Z0`9Gs)(dULKdI-FJ#N43_*|-}s1TYKmlX;3f|6I%WnELZeN5RuTEL4& zH=bRiQ9JuP-nU=gq}l$9Z9NEda6NtXn0ltR5$f;0vQ;Q6XE}`A*-y2Pl_wSm^$JP~ zXxR2Xj(T0zHKE+X=fby~+}qSHInwb>I&STg8&lG6D{1uZK`!5CWlV6$B;&>})HFWW zrH<7WLSf_jQt0pz6gA&fRw5seZOR#&Q+w;``cyMAnf(5KO88azD2u|z_I0Y$Dk+RE zJUlD#fj(9opYQk{I_gVDTd|L-fxF2X=d9D8th3Uc+?zrlrRAjr9fb_)^_k+*-tU9s zWQ`4R&=6(SV`D2ieg6dQxV+i%EqqEhZlCScN6p9-7y4o!(mUo4a0U+;PgVSm3jI`X zu!*APxyn%5PI%_FF}A=-+(!Z4Eh`&P^rC2w>0<6H8U-DNurvPrv+@u2N%cxKy=93t zPh_n3`B&k>i^4qirVeOW;>-`MaY2(U(@k%)t(s67bmf=@9N;_NoFo6{$$O`@F*JVf z2vT+srfW|wi)5Ydu(M*lH*s&7*np1Z*w;QM!mnTM=G~zdT<1;D zhF#zO?@bO{uS6fVcUH#4H$s2)$%UGCuV9x6@tv z1YdivZFkw@V1FdHM`-*T?*)wLVvEF3VSMEPPTk9*R!I#NdBVJdY@`?7SDyGuvA&ip zFxPW(Z%Mln_N<-PT2=c(8*yJQZ3sP;?m4ySO22)za{xVh`GBBnx1L|8ew*E|alKR9 zQeI)Ml)UhKKR<^)F|ank-uB`?o9VD8-yfXB<52E`;pE+;g#M&FdipHitGa8q=jzWc z^q~toFQ>Wau3BF)%fl&T;8>~^l|7JT_AQK;AxjVGIS-*sn-i#1K$(KcSwyt^EC; zy9V<5Q0RgIe(R5irYp9u+vQ)Hv_%Z4C#R?MJ~Mrs;@b7H_yx63j&kb5Y73^SwUy;A z(iQI*AJkW(DKA@ebT-dp=Q)A*-ZJ0avZ5iuw-RY+-akCfK8><1l+(xiDz<}C3>O}r z;d$-s!@<|A;(y2^`lx4()wj>UJ|g|bUfOUjQPCb@p4W;_^oV|~m_(0!IVD+Wm4#32 zMSlM{sf~fZhfnP3(b#Lz62D1(0LY+T@2O1R*=PP;7qZzWpXxK-L7RObC;tl=8jK!H z)yz8>hbW7a{Jvc%*K3bOK9%n5gWipa3mT{7e`0<${;>r`Oz$GxJ)Lh#bd<^j|1-`! z!PJfY@A$wmmbjo>(y>r#5VVO$eTWZiLvq7mH9+WY|xO1*gAh@=%z?pt-n}p=AXFyg*W|I zW?RNuz-M8Lz=Jh5um{WiCHSjM4BpTJR$#Wo1`TFDGQ8 zawf3PT$VN0=VRate!tk(lOE*XSH&*!a{cKQVLtnUrmyd}NRFh&a{mIFmxm z%~!O&?FY8;!1X!7_DCXsQ;oUUo$qW7n7d) zu#=BpJ<`;fqLM;k?JX%QR%8!jaO&(c1?dxe+eHUEj=s2m6JbJm)iOu`SN+aSlEI$V^9lBiP)v z!MSJZ`6Rd4k^cexlJ_@@<3_-#b|J=qHBjEp7w^EnhIK$~9*UR{W3=aixauk?rmUeQqQkZ z%`cME8pzn-1E^it8{r&PEni9ApC|7Vei=c}3ti&{*f(sxkV37K+{ZnnbY>hFt2vjk zms)==!BBiqN=JNdmDrHhpG^>aD#!=pj~unko4-2QNwL1WJM01SxwQ68h#_EIlb@rz z>s#%6YtG|Bykg76b;8~k`F1@y*F&woyK>~%$>W$(oq~+6a-3=i=hwOGOV4-s<}G<$2dG_Re_>RvAYq#2kxqv*Agi|YY>#b!~sDRDC z#;@9OqYq9{ZL7^5%V2&z!7Z(WT+;q}> zfVnVohjLyx@P`cghWtLvG9-VDN7XvJ$+Ajc3|z#IJN&mS&gECLtJotA+umDQ{ycBZ zy{5U3RjbOM^~mptp*bMsVc^!TbpF8^7n=lodg;+QYL%!whhcZjJ*RtxzY6d#_5OSi zz9@3Hia1vVxS>%6)5!1R>Dt3&K5I>DK?^7Bt=n-ySA~uoN)5gqNR2q((Zc*SVLrur zm36o4jX5tS&fL$kw9Y%6OE90y@+ynIw{%$NUm@p2%t2|f=5K$FPN>_C`PjvLJkuHr lZ)qECFUZ^$^ql`MX@9gWu&ZV1`|lus=8x+S33wxc{{n*XSv&v$ literal 4286 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 diff --git a/static/img/placeholder/promo.png b/static/img/placeholder/promo.png deleted file mode 100644 index 5e1fdcd892f6205eb3ac9ca6fbc93209e9941a7f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17661 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 F{|}OXU%vnV diff --git a/static/img/presente.svg b/static/img/presente.svg deleted file mode 100644 index 39fbee9..0000000 --- a/static/img/presente.svg +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - presente! - diff --git a/static/img/site.webmanifest b/static/img/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/static/img/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/static/js/qrcode.js b/static/js/qrcode.js new file mode 100644 index 0000000..5167bc0 --- /dev/null +++ b/static/js/qrcode.js @@ -0,0 +1,152 @@ +class QRCodeManager { + constructor(serverTime) { + const serverTimeMs = new Date(serverTime).getTime(); + const clientTimeMs = new Date().getTime(); + this.timeOffset = serverTimeMs - clientTimeMs; + } + + getServerTime() { + return new Date(new Date().getTime() + this.timeOffset); + } + + updateDateTime() { + const currentTimeEl = document.getElementById('current-time'); + const currentDateEl = document.getElementById('current-date'); + if (!currentTimeEl) return; + + const update = () => { + const now = this.getServerTime(); + const hours = String(now.getHours()).padStart(2, '0'); + const minutes = String(now.getMinutes()).padStart(2, '0'); + const seconds = String(now.getSeconds()).padStart(2, '0'); + currentTimeEl.textContent = hours + ':' + minutes + ':' + seconds; + + if (currentDateEl) { + const day = String(now.getDate()).padStart(2, '0'); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const year = now.getFullYear(); + currentDateEl.textContent = day + '/' + month + '/' + year; + } + }; + + update(); + setInterval(update, 1000); + } + + initActivityStartCountdown(startTime) { + const startTimeMs = new Date(startTime).getTime(); + const countdownEl = document.getElementById('start-countdown'); + if (!countdownEl) return; + + const updateCountdown = () => { + const now = this.getServerTime().getTime(); + const distance = startTimeMs - now; + + if (distance < 0) { + countdownEl.textContent = 'iniciando...'; + return; + } + + const days = Math.floor(distance / (1000 * 60 * 60 * 24)); + const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((distance % (1000 * 60)) / 1000); + + let timeString = ''; + if (days > 0) { + timeString = days + 'd ' + hours + 'h ' + minutes + 'm'; + } else if (hours > 0) { + timeString = hours + 'h ' + minutes + 'm ' + seconds + 's'; + } else if (minutes > 0) { + timeString = minutes + 'm ' + seconds + 's'; + } else { + timeString = seconds + 's'; + } + + countdownEl.textContent = timeString; + }; + + updateCountdown(); + const interval = setInterval(updateCountdown, 1000); + + const observer = new MutationObserver(function(mutations) { + if (!document.contains(countdownEl)) { + clearInterval(interval); + observer.disconnect(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + initQRCode(checkinUrl, timeout) { + let timeLeft = timeout; + + const getQRSize = () => { + const screenWidth = window.innerWidth; + const screenHeight = window.innerHeight; + + if (screenWidth < 576) { + return Math.min(300, screenWidth - 60); + } else if (screenWidth < 768) { + return Math.min(360, screenWidth - 100); + } else if (screenHeight < 800) { + return 340; + } else { + return 420; + } + }; + + const initQR = () => { + const qrcodeDiv = document.getElementById('qrcode'); + if (qrcodeDiv && !qrcodeDiv.hasChildNodes() && typeof QRCode !== 'undefined') { + const qrSize = getQRSize(); + + new QRCode(qrcodeDiv, { + text: checkinUrl, + width: qrSize, + height: qrSize, + colorDark: '#000000', + colorLight: '#ffffff', + correctLevel: QRCode.CorrectLevel.H + }); + + this.startQRCountdown(timeLeft); + } else if (typeof QRCode === 'undefined') { + setTimeout(initQR, 50); + } + }; + + initQR(); + + let resizeTimer; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + const qrcodeDiv = document.getElementById('qrcode'); + if (qrcodeDiv) { + qrcodeDiv.innerHTML = ''; + initQR(); + } + }, 250); + }); + } + + startQRCountdown(timeLeft) { + const countdownText = document.getElementById('countdown-text'); + const qrcodeDiv = document.getElementById('qrcode'); + if (!countdownText) return; + + const interval = setInterval(() => { + timeLeft--; + countdownText.textContent = timeLeft; + + if (timeLeft === 3 && qrcodeDiv) { + qrcodeDiv.classList.add('qr-fade-out'); + } + + if (timeLeft <= 0) { + clearInterval(interval); + } + }, 1000); + } +} diff --git a/templates/403.html b/templates/403.html index e811ff7..2b51a33 100644 --- a/templates/403.html +++ b/templates/403.html @@ -86,7 +86,7 @@

Acesso Negado

Você não tem permissão para acessar esta página.

-
+ Voltar para o Início diff --git a/templates/404.html b/templates/404.html index a388513..a9ab9d9 100644 --- a/templates/404.html +++ b/templates/404.html @@ -86,7 +86,7 @@

Página Não Encontrada

Ops! A página que você está procurando não existe.

- + Voltar para o Início diff --git a/templates/500.html b/templates/500.html index e37ac7e..c02d513 100644 --- a/templates/500.html +++ b/templates/500.html @@ -86,7 +86,7 @@

Erro Interno do Servidor

Algo deu errado. Estamos trabalhando para resolver o problema.

- + Voltar para o Início diff --git a/templates/presente/activity_detail.html b/templates/presente/activity_detail.html index 2fed770..c0b3d05 100644 --- a/templates/presente/activity_detail.html +++ b/templates/presente/activity_detail.html @@ -29,15 +29,20 @@
-
+
Link Público
- {% if is_expired %} + {% if object.status == 'expired' %}
Atividade Encerrada

Esta atividade já encerrou. Não é mais possível registrar novas presenças.

+ {% elif object.status == 'not_enabled' %} +
+ Atividade Desabilitada +

É necessário que atividade esteja habilitada para registrar novas presenças.

+
{% else %}

Compartilhe este link para que os participantes possam registrar presença:

diff --git a/templates/presente/attendance_print.html b/templates/presente/attendance_print.html index 9f7d4ab..1208ca0 100644 --- a/templates/presente/attendance_print.html +++ b/templates/presente/attendance_print.html @@ -1,189 +1,11 @@ +{% load static %} Relatório de Presenças - {{ activity.title }} - +
+
+ + presente! +

Relatório de Presenças

{{ activity.title }}
@@ -274,7 +100,8 @@

Filtros Aplicados:

{% endif %} diff --git a/templates/presente/checkin_done.html b/templates/presente/checkin_done.html new file mode 100644 index 0000000..f2ffd69 --- /dev/null +++ b/templates/presente/checkin_done.html @@ -0,0 +1,67 @@ +{% extends 'presente/base.html' %} +{% load static %} + +{% block title %} + {% if success %} + {% if created %}Presença Registrada{% else %}Presença Já Registrada{% endif %} + {% else %} + Erro no Check-in + {% endif %} +{% endblock %} + +{% block body_class %}{% endblock %} + +{% block app_wrapper %} +
+
+
+
+
+ {% if success %} + {% if created %} +
+ +
+

Presença Registrada!

+

Sua presença na atividade foi registrada com sucesso.

+ {% else %} +
+ +
+

Presença Já Registrada

+

Você já havia registrado presença nesta atividade.

+ {% endif %} + +
+ +
+

Atividade: {{ activity.title }}

+

Participante: {{ attendance.user.get_full_name|default:attendance.user.email }}

+

Registrado em: {{ attendance.checked_in_at|date:"d/m/Y H:i:s" }}

+
+ {% else %} +
+ +
+

Erro no Check-in

+

{{ error }}

+ + {% if activity %} +
+
+

Atividade: {{ activity.title }}

+
+ {% endif %} + {% endif %} + + +
+
+
+
+
+{% endblock %} diff --git a/templates/presente/checkin_error.html b/templates/presente/checkin_error.html deleted file mode 100644 index d5d720b..0000000 --- a/templates/presente/checkin_error.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends 'presente/base.html' %} -{% load static %} - -{% block title %}Erro no Check-in{% endblock %} - -{% block body_class %}{% endblock %} - -{% block app_wrapper %} -
-
-
-
-
-
- -
-

Erro no Check-in

-

{{ error }}

- - {% if activity %} -
-
-

Atividade: {{ activity.title }}

-
- {% endif %} - - -
-
-
-
-
-{% endblock %} diff --git a/templates/presente/checkin_success.html b/templates/presente/checkin_success.html deleted file mode 100644 index d5fcdb4..0000000 --- a/templates/presente/checkin_success.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends 'presente/base.html' %} -{% load static %} - -{% block title %}Presença Registrada{% endblock %} - -{% block body_class %}{% endblock %} - -{% block app_wrapper %} -
-
-
-
-
- {% if created %} -
- -
-

Presença Registrada!

-

Sua presença na atividade foi registrada com sucesso.

- {% else %} -
- -
-

Presença Já Registrada

-

Você já havia registrado presença nesta atividade.

- {% endif %} - -
- -
-

Atividade: {{ activity.title }}

-

Participante: {{ attendance.user.get_full_name|default:attendance.user.email }}

-

Registrado em: {{ attendance.checked_in_at|date:"d/m/Y H:i:s" }}

-
- - -
-
-
-
-
-{% endblock %} diff --git a/templates/presente/includes/qr_code.html b/templates/presente/includes/qr_code.html deleted file mode 100644 index 0462781..0000000 --- a/templates/presente/includes/qr_code.html +++ /dev/null @@ -1,94 +0,0 @@ -
- -
- Renovando em {{ timeout }}s -
-
- -
- - diff --git a/templates/presente/includes/qr_content.html b/templates/presente/includes/qr_content.html new file mode 100644 index 0000000..92dbe81 --- /dev/null +++ b/templates/presente/includes/qr_content.html @@ -0,0 +1,64 @@ +{% load static %} + +{% if activity.status == 'not_started' %} +
+ +
Aguarde o Início
+

O QR Code será exibido automaticamente quando o registro de presença estiver disponível.

+ +
+
+ + Inicia em: calculando... +
+
+
+ +{% elif activity.status == 'expired' %} +
+ +
Atividade Encerrada
+

Esta atividade já encerrou. Não é mais possível registrar presença.

+
+ +{% elif activity.status == 'active' %} +
Escaneie ou clique para registrar presença
+ +
+ +
+ Renovando em {{ timeout }}s +
+
+ +
+ +{% else %} +
+ +
Erro
+

Erro ao gerar QR Code. Atividade não encontrada.

+
+{% endif %} + + diff --git a/templates/presente/includes/qr_error.html b/templates/presente/includes/qr_error.html deleted file mode 100644 index c498161..0000000 --- a/templates/presente/includes/qr_error.html +++ /dev/null @@ -1,3 +0,0 @@ -
- Erro ao gerar QR Code. Atividade não encontrada. -
diff --git a/templates/presente/includes/qr_expired.html b/templates/presente/includes/qr_expired.html deleted file mode 100644 index 5913a7a..0000000 --- a/templates/presente/includes/qr_expired.html +++ /dev/null @@ -1,5 +0,0 @@ -
- -
Atividade Encerrada
-

Esta atividade já encerrou. Não é mais possível registrar presença.

-
diff --git a/templates/presente/includes/socialmedia_table.html b/templates/presente/includes/socialmedia_table.html deleted file mode 100644 index 6863496..0000000 --- a/templates/presente/includes/socialmedia_table.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - {% for socialmedia in page_obj %} - - - - - {% include "cms/includes/actions.html" with obj=socialmedia %} - - {% endfor %} - -
#URLÍconeAções
{{ socialmedia.id }}{{ socialmedia.url }}{{ socialmedia.icon }}
diff --git a/templates/presente/ip_restricted.html b/templates/presente/ip_restricted.html deleted file mode 100644 index 6ec9567..0000000 --- a/templates/presente/ip_restricted.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "presente/base.html" %} -{% load static %} - -{% block title %}Acesso Restrito{% endblock %} - -{% block content %} -
-
-
-
-
-
-

- Acesso Restrito -

-
-
-
- -
- -
Acesso Negado por Restrição de IP
- -

- Esta atividade possui restrição de acesso por endereço IP. - Seu endereço IP não está autorizado a acessar esta página. -

- -
-

Atividade: {{ activity.title }}

-

- Seu IP: {{ client_ip }} -

-
- -
-

- - Se você acredita que deveria ter acesso a esta atividade, - entre em contato com os responsáveis ou conecte-se à rede autorizada. -

-
- - -
-
-
-
-
-
-{% endblock %} diff --git a/templates/presente/private_base.html b/templates/presente/private_base.html index 93c8c66..5ccd6fa 100644 --- a/templates/presente/private_base.html +++ b/templates/presente/private_base.html @@ -4,7 +4,7 @@ {% block title %}{{ page_title }}{% endblock %} {% block extra_head %} -{% endblock %} +{% endblock extra_head %} {% block app_wrapper %}
@@ -149,4 +149,4 @@
-{% endblock %} +{% endblock app_wrapper %} diff --git a/templates/presente/public_activity.html b/templates/presente/public_activity.html index a9f9562..dad06e8 100644 --- a/templates/presente/public_activity.html +++ b/templates/presente/public_activity.html @@ -5,10 +5,6 @@ {% block body_class %}public-activity-page{% endblock %} -{% block extra_head %} - -{% endblock %} - {% block app_wrapper %}
@@ -29,7 +25,11 @@

{{ activity.title }}

-
Escaneie ou clique para registrar presença
+
+ + + +
Erro de Conexão
+{% block extra_script %} + + + {% endblock %} + +{% endblock %} From 0ffc83340b7991f0e06f17b9407c8635ead2506e Mon Sep 17 00:00:00 2001 From: Diego Cirilo Date: Sun, 30 Nov 2025 01:59:04 -0300 Subject: [PATCH 2/2] refactor --- .../0014_alter_activity_is_enabled.py | 18 ++++++++++++++++++ presente/tables.py | 2 +- presente/utils.py | 5 +---- presente/views.py | 10 ++++++++-- 4 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 presente/migrations/0014_alter_activity_is_enabled.py diff --git a/presente/migrations/0014_alter_activity_is_enabled.py b/presente/migrations/0014_alter_activity_is_enabled.py new file mode 100644 index 0000000..73e7b25 --- /dev/null +++ b/presente/migrations/0014_alter_activity_is_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.7 on 2025-11-30 03:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('presente', '0013_activity_is_enabled_alter_activity_owners_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='is_enabled', + field=models.BooleanField(default=True, help_text='Habilitar o registro de presença.', verbose_name='Habilitar?'), + ), + ] diff --git a/presente/tables.py b/presente/tables.py index cd339af..bac15b2 100644 --- a/presente/tables.py +++ b/presente/tables.py @@ -38,7 +38,7 @@ def render_status(self, record): class Meta: model = Activity - fields = ("title", "status", "tags_list", "start_time", "end_time") + fields = ("title", "tags_list", "start_time", "end_time", "status") class AttendanceTable(django_tables2.Table): diff --git a/presente/utils.py b/presente/utils.py index 6b36692..68288d7 100644 --- a/presente/utils.py +++ b/presente/utils.py @@ -1,6 +1,7 @@ from django.conf import settings import hashlib import base64 +import time def get_client_ip(request): @@ -62,8 +63,6 @@ def decode_activity_id(encoded_id): def generate_checkin_token(activity_id, timeout_seconds): - import time - # Get current timestamp timestamp = int(time.time()) @@ -85,8 +84,6 @@ def generate_checkin_token(activity_id, timeout_seconds): def verify_checkin_token(token, timeout_seconds): - import time - try: # Add padding back if needed padding = 4 - (len(token) % 4) diff --git a/presente/views.py b/presente/views.py index 732bef8..1cb63a5 100644 --- a/presente/views.py +++ b/presente/views.py @@ -120,6 +120,9 @@ class ActivityUpdateView(CoreUpdateView): page_title = _("Atividades") form_class = ActivityForm + def get_success_url(self): + return reverse_lazy("presente:activity_view", kwargs={"pk": self.kwargs["pk"]}) + def get_queryset(self): if self.request.user.is_superuser: return Activity.objects.all() @@ -213,14 +216,16 @@ def get_context_data(self, **kwargs): "Acesso negado. Seu IP ({ip}) não tem permissão para registrar presença nesta atividade." ).format(ip=client_ip) context["client_ip"] = client_ip - elif activity.is_not_started(): + elif activity.status == "not_started": context["error"] = _( "Esta atividade ainda não começou. Não é possível registrar presença." ) - elif activity.is_expired(): + elif activity.status == "expired": context["error"] = _( "Esta atividade já encerrou. Não é mais possível registrar presença." ) + elif activity.status == "not_enabled": + context["error"] = _("Esta atividade não está aceitando presenças.") elif not verify_checkin_token(token, activity.qr_timeout): context["error"] = _("QR Code expirado. Solicite um novo código.") else: @@ -297,6 +302,7 @@ def get_allowed_actions(self): class AttendanceDeleteView(CoreDeleteView): model = Attendance success_message = _("Presença removida com sucesso!") + permission_required = [] def get_queryset(self): # Get the attendance and check if user is owner of the activity or superuser