Skip to content
Merged

Dev #13

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion presente/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ class ActivityAttendanceTable(django_tables2.Table):
accessor="user",
verbose_name=_("Nome"),
orderable=True,
order_by=("user_name_collated", "user__full_name"),
order_by=("user__full_name_normalized", "user__full_name"),
)
user_type = django_tables2.Column(
accessor="user__type",
Expand Down
69 changes: 47 additions & 22 deletions presente/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,20 +322,8 @@ def get_page_title(self):
return _("Presenças - {}").format(activity.title)

def get_queryset(self):
from django.db import connection
from django.db.models import F

activity = self.get_activity()
qs = Attendance.objects.filter(activity=activity).select_related("user")

if connection.vendor == "postgresql":
from django.db.models.functions import Collate

qs = qs.annotate(user_name_collated=Collate("user__full_name", "pt_BR"))
else:
# SQLite: just alias the field for compatibility
qs = qs.annotate(user_name_collated=F("user__full_name"))

return qs.order_by("-checked_in_at")

def get_context_data(self, **kwargs):
Expand Down Expand Up @@ -415,6 +403,7 @@ class ActivityAttendancePDFView(
template_name = "presente/attendance_pdf.html"
context_object_name = "attendances"
pdf_filename = "relatorio_presencas.pdf"
pdf_attachment = False # Display inline in browser instead of downloading

def get_pdf_filename(self):
activity = self.get_activity()
Expand All @@ -434,8 +423,8 @@ def get_queryset(self):
if sort_by:
# Map sort fields to actual model fields
sort_mapping = {
"name": "user__full_name",
"-name": "-user__full_name",
"name": "user__full_name_normalized",
"-name": "-user__full_name_normalized",
"type": "user__type",
"-type": "-user__type",
"curso": "user__curso",
Expand All @@ -445,10 +434,10 @@ def get_queryset(self):
"checked_in_at": "checked_in_at",
"-checked_in_at": "-checked_in_at",
}
sort_field = sort_mapping.get(sort_by, "user__full_name")
sort_field = sort_mapping.get(sort_by, "user__full_name_normalized")
qs = qs.order_by(sort_field)
else:
qs = qs.order_by("user__full_name")
qs = qs.order_by("user__full_name_normalized")

return qs

Expand All @@ -457,8 +446,27 @@ def get_context_data(self, **kwargs):
activity = self.get_activity()
context["activity"] = activity

# Get the filtered queryset (after filters are applied)
filtered_qs = context["filter"].qs
# Get the filtered queryset (after filters are applied) and apply sorting
sort_by = self.request.GET.get("sort_by", "name")
sort_mapping = {
"name": "user__full_name_normalized",
"-name": "-user__full_name_normalized",
"type": "user__type",
"-type": "-user__type",
"curso": "user__curso",
"-curso": "-user__curso",
"periodo": "user__periodo_referencia",
"-periodo": "-user__periodo_referencia",
"checked_in_at": "checked_in_at",
"-checked_in_at": "-checked_in_at",
}
sort_field = sort_mapping.get(sort_by, "user__full_name_normalized")

# Apply sorting to the filtered queryset
filtered_qs = context["filter"].qs.order_by(sort_field)

# Store the sorted queryset for the template
context["sorted_qs"] = filtered_qs
context["total_attendances"] = filtered_qs.count()

# Get column configuration
Expand Down Expand Up @@ -511,8 +519,8 @@ def get_queryset(self):
# Apply sorting
sort_by = self.request.GET.get("sort_by", "name")
sort_mapping = {
"name": "user__full_name",
"-name": "-user__full_name",
"name": "user__full_name_normalized",
"-name": "-user__full_name_normalized",
"type": "user__type",
"-type": "-user__type",
"curso": "user__curso",
Expand All @@ -522,7 +530,7 @@ def get_queryset(self):
"checked_in_at": "checked_in_at",
"-checked_in_at": "-checked_in_at",
}
sort_field = sort_mapping.get(sort_by, "user__full_name")
sort_field = sort_mapping.get(sort_by, "user__full_name_normalized")
qs = qs.order_by(sort_field)

return qs
Expand All @@ -532,6 +540,23 @@ def get(self, request, *args, **kwargs):
filterset = self.filterset_class(request.GET, queryset=self.get_queryset())
queryset = filterset.qs

# Reapply sorting to ensure it's maintained after filtering
sort_by = request.GET.get("sort_by", "name")
sort_mapping = {
"name": "user__full_name_normalized",
"-name": "-user__full_name_normalized",
"type": "user__type",
"-type": "-user__type",
"curso": "user__curso",
"-curso": "-user__curso",
"periodo": "user__periodo_referencia",
"-periodo": "-user__periodo_referencia",
"checked_in_at": "checked_in_at",
"-checked_in_at": "-checked_in_at",
}
sort_field = sort_mapping.get(sort_by, "user__full_name_normalized")
queryset = queryset.order_by(sort_field)

# Get column configuration
columns = request.GET.getlist("columns")
if not columns:
Expand Down Expand Up @@ -578,7 +603,7 @@ def get(self, request, *args, **kwargs):
if "number" in columns:
row.append(idx)
if "name" in columns:
row.append(attendance.user.get_full_name())
row.append(attendance.user.get_full_name().upper())
if "email" in columns:
row.append(attendance.user.email or "-")
if "matricula" in columns:
Expand Down
2 changes: 1 addition & 1 deletion templates/core/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<div class="collapse {% if filters_active %}show{% endif %} mb-3" id="filterCollapse">
<div class="card">
<div class="card-body">
<form method="get" class="row g-3" hx-get="{{ request.path }}" hx-target="#table-wrapper" hx-push-url="true" hx-trigger="change delay:300ms, submit">
<form method="get" id="filter-form" class="row g-3" hx-get="{{ request.path }}" hx-target="#table-wrapper" hx-push-url="true" hx-trigger="change delay:300ms, submit">
{% for field in filter.form %}
{% if field.name != 'csrfmiddlewaretoken' %}
<div class="col-md-4">
Expand Down
1 change: 1 addition & 0 deletions templates/presente/activity_attendance_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
hx-get="{% url 'presente:activity_attendance_export_config' activity.pk %}?{{ request.GET.urlencode }}"
hx-target="#modals-here .modal-content"
hx-trigger="click"
hx-include="#filter-form"
data-bs-toggle="modal"
data-bs-target="#modals-here">
<i class="bi bi-download"></i> Exportar Relatório
Expand Down
6 changes: 3 additions & 3 deletions templates/presente/attendance_pdf.html
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ <h3>Filtros Aplicados:</h3>
<strong>Total de Presenças:</strong> {{ total_attendances }}
</div>

{% if filter.qs %}
{% if sorted_qs %}
<table>
<thead>
<tr>
Expand All @@ -212,10 +212,10 @@ <h3>Filtros Aplicados:</h3>
</tr>
</thead>
<tbody>
{% for attendance in filter.qs %}
{% for attendance in sorted_qs %}
<tr>
{% if "number" in columns %}<td>{{ forloop.counter }}</td>{% endif %}
{% if "name" in columns %}<td>{{ attendance.user.get_full_name }}</td>{% endif %}
{% if "name" in columns %}<td>{{ attendance.user.get_full_name|upper }}</td>{% endif %}
{% if "email" in columns %}<td>{{ attendance.user.email|default:"-" }}</td>{% endif %}
{% if "matricula" in columns %}<td>{{ attendance.user.matricula|default:"-" }}</td>{% endif %}
{% if "type" in columns %}<td>{{ attendance.user.get_type_display }}</td>{% endif %}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ <h5 class="modal-title">

<!-- Preserve filter parameters -->
{% for key, value in filter_params.items %}
{% if key not in "columns,sort_by" %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}

<div class="mb-4">
Expand Down
18 changes: 18 additions & 0 deletions users/migrations/0010_user_full_name_normalized.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2025-12-04 04:56

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('users', '0009_remove_is_published'),
]

operations = [
migrations.AddField(
model_name='user',
name='full_name_normalized',
field=models.CharField(blank=True, db_index=True, help_text='Nome sem acentos para ordenação', max_length=255, null=True, verbose_name='Nome normalizado'),
),
]
34 changes: 34 additions & 0 deletions users/migrations/0011_populate_full_name_normalized.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Generated by Django 5.2.7 on 2025-12-04 04:56

from django.db import migrations
import unicodedata


def populate_normalized_names(apps, schema_editor):
User = apps.get_model('users', 'User')
for user in User.objects.all():
if user.full_name:
# NFD = Canonical Decomposition (separates base chars from accents)
nfd = unicodedata.normalize('NFD', user.full_name)
# Filter out combining characters (accents) and convert to uppercase
user.full_name_normalized = ''.join(
char for char in nfd
if unicodedata.category(char) != 'Mn'
).upper()
user.save(update_fields=['full_name_normalized'])


def reverse_populate(apps, schema_editor):
User = apps.get_model('users', 'User')
User.objects.update(full_name_normalized=None)


class Migration(migrations.Migration):

dependencies = [
('users', '0010_user_full_name_normalized'),
]

operations = [
migrations.RunPython(populate_normalized_names, reverse_populate),
]
21 changes: 21 additions & 0 deletions users/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.contrib.auth.models import AbstractUser
from django.utils.translation import gettext_lazy as _
from .managers import CustomUserManager
import unicodedata


class User(AbstractUser):
Expand All @@ -20,6 +21,14 @@ class UserType(models.TextChoices):
null=True,
help_text=_("Nome completo do usuário (usa nome social se disponível)"),
)
full_name_normalized = models.CharField(
_("Nome normalizado"),
max_length=255,
blank=True,
null=True,
help_text=_("Nome sem acentos para ordenação"),
db_index=True,
)
type = models.CharField(
_("Tipo"),
max_length=20,
Expand Down Expand Up @@ -72,6 +81,18 @@ class Meta:
def save(self, *args, **kwargs):
if not self.username:
self.username = self.email

# Normalize full_name for sorting (remove accents and normalize case)
if self.full_name:
# NFD = Canonical Decomposition (separates base chars from accents)
nfd = unicodedata.normalize("NFD", self.full_name)
# Filter out combining characters (accents) and convert to uppercase
self.full_name_normalized = "".join(
char for char in nfd if unicodedata.category(char) != "Mn"
).upper()
else:
self.full_name_normalized = None

super().save(*args, **kwargs)

@property
Expand Down
2 changes: 1 addition & 1 deletion users/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class UserTable(CoreTable):
full_name = tables.Column(
verbose_name=_("Nome"),
empty_values=(),
order_by=("full_name_collated", "full_name"),
order_by=("full_name_normalized", "full_name"),
)
matricula = tables.Column(verbose_name=_("Matrícula"), empty_values=())
type = tables.Column(verbose_name=_("Tipo"), empty_values=())
Expand Down
16 changes: 1 addition & 15 deletions users/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ExcludeAdminMixin:

def get_queryset(self):
base_qs = super().get_queryset()
return base_qs.exclude(id=self.admin_id).order_by("id")
return base_qs.exclude(id=self.admin_id)


class CustomPasswordResetFromKeyView(PasswordResetFromKeyView):
Expand All @@ -44,23 +44,9 @@ class UserListView(ExcludeAdminMixin, CoreFilterView):
filterset_class = UserFilter

def get_queryset(self):
from django.db import connection
from django.db.models import F

queryset = super().get_queryset()
# Prefetch social accounts to avoid N+1 queries when accessing matricula
queryset = queryset.prefetch_related("socialaccount_set")

if connection.vendor == "postgresql":
from django.db.models.functions import Collate

queryset = queryset.annotate(
full_name_collated=Collate("full_name", "pt_BR")
)
else:
# SQLite: just alias the field for compatibility
queryset = queryset.annotate(full_name_collated=F("full_name"))

return queryset


Expand Down