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/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/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..bac15b2 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", "tags_list", "start_time", "end_time", "status") 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/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 6776d11..1cb63a5 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 @@ -129,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() @@ -150,131 +144,106 @@ 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, TemplateView): + template_name = "presente/checkin_done.html" -class CheckInView(LoginRequiredMixin, View): - def get(self, request, token): - # Default template and context - template = "presente/checkin_error.html" - context = {} + 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 = _( + elif activity.status == "not_started": + 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 = _( + elif activity.status == "expired": + 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 activity.status == "not_enabled": + context["error"] = _("Esta atividade não está aceitando presenças.") 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 +253,7 @@ class MyAttendancesView(CoreFilterView): filterset_class = AttendanceFilter table_pagination = {"per_page": 20} actions = [] + permission_required = [] def get_queryset(self): return ( @@ -302,6 +272,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() @@ -331,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 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 eaeae51..e5d67e9 100644 Binary files a/static/img/favicon.ico and b/static/img/favicon.ico differ diff --git a/static/img/placeholder/cover.png b/static/img/placeholder/cover.png deleted file mode 100644 index 3f35064..0000000 Binary files a/static/img/placeholder/cover.png and /dev/null differ diff --git a/static/img/placeholder/promo.png b/static/img/placeholder/promo.png deleted file mode 100644 index 5e1fdcd..0000000 Binary files a/static/img/placeholder/promo.png and /dev/null differ 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 @@ - - 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 @@
Esta atividade já encerrou. Não é mais possível registrar novas presenças.
É necessário que atividade esteja habilitada para registrar novas presenças.
+Compartilhe este link para que os participantes possam registrar presença: