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 @@ - - - - - - - - - - - - - - - - - - - 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 %}