diff --git a/.github/workflows/quality-pipeline.yml b/.github/workflows/quality-pipeline.yml index 5aaadf7..a9826c9 100644 --- a/.github/workflows/quality-pipeline.yml +++ b/.github/workflows/quality-pipeline.yml @@ -645,7 +645,7 @@ jobs: FAIL=0 if find . \( -name "*.js" -o -name "*.ts" -o -name "*.vue" -o -name "*.py" -o -name "*.java" \) \ - -not -path "*/node_modules/*" -not -path "*/dist/*" 2>/dev/null | grep -q .; then + -not -path "*/node_modules/*" -not -path "*/dist/*" -not -path "*/tests/*" -not -path "*/.venv/*" 2>/dev/null | grep -q .; then DETECTED=1 else DETECTED=0 @@ -654,7 +654,7 @@ jobs: # Unconditional frontend router guard bypass if grep -rEn "next\(true\)|beforeEach.*return true|navigate\(.*\{.*replace.*\}\)" \ --include="*.js" --include="*.ts" --include="*.vue" . 2>/dev/null \ - | grep -v node_modules | grep -v "//"; then + | grep -vE "node_modules|tests/|\.venv|dist/" | grep -v "//"; then echo "::warning::Possible unconditional navigation guard bypass detected" fi @@ -662,14 +662,14 @@ jobs: if grep -rEn "password\s*[=:]\s*['\"][^'\"]{4,}['\"]" \ --include="*.js" --include="*.ts" --include="*.vue" --include="*.py" --include="*.java" \ . 2>/dev/null \ - | grep -v node_modules \ + | grep -vE "node_modules|tests/|\.venv|dist/" \ | grep -v "//" \ | grep -vE "[[:space:]]:[a-zA-Z][a-zA-Z0-9_-]*=" \ | grep -q .; then grep -rEn "password\s*[=:]\s*['\"][^'\"]{4,}['\"]" \ --include="*.js" --include="*.ts" --include="*.vue" --include="*.py" --include="*.java" \ . 2>/dev/null \ - | grep -v node_modules | grep -v "//" \ + | grep -vE "node_modules|tests/|\.venv|dist/" | grep -v "//" \ | grep -vE "[[:space:]]:[a-zA-Z][a-zA-Z0-9_-]*=" echo "::error::Hardcoded password value detected in source code" FAIL=1 @@ -678,14 +678,14 @@ jobs: # eval() / exec() usage if grep -rEn "\beval\(|\bexec\(" \ --include="*.js" --include="*.ts" --include="*.vue" --include="*.py" \ - . 2>/dev/null | grep -v node_modules | grep -v "//"; then + . 2>/dev/null | grep -vE "node_modules|tests/|\.venv|dist/" | grep -v "//"; then echo "::warning::eval()/exec() usage detected — review for potential code injection risk" fi # Disabled SSL/TLS certificate verification if grep -rEn "rejectUnauthorized\s*:\s*false|verify\s*=\s*False|ssl_verify\s*=\s*false" \ --include="*.js" --include="*.ts" --include="*.py" --include="*.java" \ - . 2>/dev/null | grep -v node_modules | grep -v "//"; then + . 2>/dev/null | grep -vE "node_modules|tests/|\.venv|dist/" | grep -v "//"; then echo "::error::SSL/TLS certificate verification disabled — man-in-the-middle attack risk" FAIL=1 fi @@ -693,7 +693,7 @@ jobs: # Sensitive tokens in URL query strings if grep -rEn "[?&](token|api_key|apikey|access_token|secret)=" \ --include="*.js" --include="*.ts" --include="*.vue" --include="*.py" \ - . 2>/dev/null | grep -v node_modules | grep -v "//"; then + . 2>/dev/null | grep -vE "node_modules|tests/|\.venv|dist/" | grep -v "//"; then echo "::warning::Sensitive token/key passed in URL query string — use Authorization header instead." fi diff --git a/Dockerfile b/Dockerfile index 765eebd..fe6ea71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,4 +12,4 @@ EXPOSE 8000 RUN python manage.py collectstatic --noinput -CMD python3 manage.py migrate --fake-initial && gunicorn web.wsgi:application --bind 0.0.0.0:${PORT:-8000} --timeout 90 \ No newline at end of file +CMD python3 manage.py migrate --fake-initial && python3 manage.py load_municipalities && gunicorn web.wsgi:application --bind 0.0.0.0:${PORT:-8000} --timeout 90 \ No newline at end of file diff --git a/Templates/Details/details_base.html b/Templates/Details/details_base.html index 3412911..8eefb20 100644 --- a/Templates/Details/details_base.html +++ b/Templates/Details/details_base.html @@ -48,7 +48,6 @@

{{ content.title }}

data-contingut-id="{{ content.contingut.id }}" data-api-id="{{ api.id }}"> {{ api.name|default:api.port }} - {% empty %} {% trans "No hay información de API disponible." %} @@ -137,15 +136,35 @@

{% trans "Deja tu opinión" %}

✅ {% trans "Añadido a tu historial" %}
{% endblock %} \ No newline at end of file diff --git a/Templates/Details/details_serie.html b/Templates/Details/details_serie.html index 9eaa8e6..e6fc94a 100644 --- a/Templates/Details/details_serie.html +++ b/Templates/Details/details_serie.html @@ -17,4 +17,27 @@ {% endif %} {% endblock %} -{% block favorite_url %}{% url 'toggle_series_favorite' content.id %}{% endblock %} \ No newline at end of file +{% block favorite_url %}{% url 'toggle_series_favorite' content.id %}{% endblock %} + +{% block extra_main_content %} +
+

{% trans "También te puede gustar..." %}

+
+ {% for rec in recommendations %} + + {% if rec.poster_path %} + {{ rec.title }} + {% else %} +
+ {{ rec.title }} +
+ {% endif %} +

{{ rec.title }}

+
+ {% empty %} + +

{% trans "No hay recomendaciones disponibles." %}

+ {% endfor %} +
+
+{% endblock %} \ No newline at end of file diff --git a/Templates/base.html b/Templates/base.html index 9f42415..ededc38 100644 --- a/Templates/base.html +++ b/Templates/base.html @@ -125,7 +125,7 @@ {% endif %} - + @@ -174,7 +175,11 @@
{% trans "Legal" %}
{% trans "Contact" %}
-

info@StreamSync.com

+

+ + info@StreamSync.com + +

+34 600 000 000

diff --git a/Templates/identify/register.html b/Templates/identify/register.html index ebdca10..0dc0d2a 100644 --- a/Templates/identify/register.html +++ b/Templates/identify/register.html @@ -101,6 +101,51 @@

{% trans "Create Account" %}

{% endfor %} +
+ + {% for error in form.gender.errors %} + {{ error }} + {% endfor %} +
+ +
+ + {% for error in form.age_range.errors %} + {{ error }} + {% endfor %} +
+ +
+ + {% for error in form.province.errors %} + {{ error }} + {% endfor %} +
+ +
+ + {% for error in form.municipality.errors %} + {{ error }} + {% endfor %} +
+
diff --git a/users/templates/users/parts/dashboard_age_ratings.html b/users/templates/users/parts/dashboard_age_ratings.html index 85c41eb..c65a0d1 100644 --- a/users/templates/users/parts/dashboard_age_ratings.html +++ b/users/templates/users/parts/dashboard_age_ratings.html @@ -7,7 +7,7 @@
-
Age Rating Distribution
+
Views by Age Rating
@@ -27,7 +27,7 @@
Age Rating Table
Rating - Views + Visualizations @@ -54,7 +54,7 @@
Age Rating Table
data: { labels: {{ age_stats.labels|safe }}, datasets: [{ - label: 'Views', + label: 'Visualizations', data: {{ age_stats.values|safe }}, backgroundColor: '#3498db', borderRadius: 5 diff --git a/users/templates/users/parts/dashboard_cross_tab.html b/users/templates/users/parts/dashboard_cross_tab.html new file mode 100644 index 0000000..55ec2ef --- /dev/null +++ b/users/templates/users/parts/dashboard_cross_tab.html @@ -0,0 +1,97 @@ +{% extends 'users/types/user_admin.html' %} +{% load i18n %} + +{% block parts_content %} +
+ {% include 'users/parts/filter_form.html' %} + + + +
+
+
+
+
{% trans 'Views by Gender and Age Range' %}
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
{% trans 'Cross-tabulation Data' %}
+
+
+
+ + + + + {% for header in cross_table_headers %} + + {% endfor %} + + + + {% for row in cross_table_rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{% trans 'Gender' %}{{ header }}
{{ cell }}
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/users/templates/users/parts/dashboard_demographics.html b/users/templates/users/parts/dashboard_demographics.html new file mode 100644 index 0000000..f6bca3c --- /dev/null +++ b/users/templates/users/parts/dashboard_demographics.html @@ -0,0 +1,122 @@ +{% extends 'users/types/user_admin.html' %} +{% load i18n %} + +{% block parts_content %} +
+ {% include 'users/parts/filter_form.html' %} + + + +
+
+
+
+
{% trans 'Views by Gender' %}
+
+
+
+ +
+
+
+
+ +
+
+
+
{% trans 'Views by Age Range' %}
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
{% trans 'Views by Province (Top 10)' %}
+
+
+
+ +
+
+
+
+ +
+
+
+
{% trans 'Gender x Age Range' %}
+
+
+
+ +
+
+
+
+
+
+ + +{% endblock %} diff --git a/users/templates/users/parts/dashboard_directors.html b/users/templates/users/parts/dashboard_directors.html index ab4abc3..9c62a15 100644 --- a/users/templates/users/parts/dashboard_directors.html +++ b/users/templates/users/parts/dashboard_directors.html @@ -7,7 +7,7 @@
-
Top Directors
+
Top Directors by Views
@@ -27,7 +27,7 @@
Top Directors Table
Director - Views + Visualizations @@ -54,7 +54,7 @@
Top Directors Table
data: { labels: {{ director_stats.labels|safe }}, datasets: [{ - label: 'Views', + label: 'Visualizations', data: {{ director_stats.values|safe }}, backgroundColor: '#2c3e50', borderRadius: 5 diff --git a/users/templates/users/parts/dashboard_genres.html b/users/templates/users/parts/dashboard_genres.html index ab8fc21..d494d15 100644 --- a/users/templates/users/parts/dashboard_genres.html +++ b/users/templates/users/parts/dashboard_genres.html @@ -7,7 +7,7 @@
-
Genre Distribution
+
Views by Genre
@@ -27,7 +27,7 @@
Top Genres Data
Genre - Views + Visualizations diff --git a/users/templates/users/parts/dashboard_overview.html b/users/templates/users/parts/dashboard_overview.html index f66f0f2..237ce03 100644 --- a/users/templates/users/parts/dashboard_overview.html +++ b/users/templates/users/parts/dashboard_overview.html @@ -5,8 +5,8 @@ {% include 'users/parts/filter_form.html' %}
-
Global Content Analytics Summary
- Views per Content + Export CSV @@ -22,7 +22,7 @@
Global Content Analytics Summary
Year Director Platform - Views + Visualizations Favorites diff --git a/users/templates/users/parts/dashboard_views_by_age.html b/users/templates/users/parts/dashboard_views_by_age.html new file mode 100644 index 0000000..2cda0d3 --- /dev/null +++ b/users/templates/users/parts/dashboard_views_by_age.html @@ -0,0 +1,81 @@ +{% extends 'users/types/user_admin.html' %} +{% load i18n %} + +{% block parts_content %} +
+ {% include 'users/parts/filter_form.html' %} + + + +
+
+
+
+
{% trans 'Views by Age Range' %}
+
+
+
+ +
+
+
+
+
+
+
+
{% trans 'Age Range Data' %}
+
+
+
+ + + + + + + + + {% for label, value in age_table %} + + + + + {% endfor %} + +
{% trans 'Age Range' %}{% trans 'Views' %}
{{ label }}{{ value }}
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/users/templates/users/parts/dashboard_views_by_gender.html b/users/templates/users/parts/dashboard_views_by_gender.html new file mode 100644 index 0000000..b2a1a51 --- /dev/null +++ b/users/templates/users/parts/dashboard_views_by_gender.html @@ -0,0 +1,81 @@ +{% extends 'users/types/user_admin.html' %} +{% load i18n %} + +{% block parts_content %} +
+ {% include 'users/parts/filter_form.html' %} + + + +
+
+
+
+
{% trans 'Views by Gender' %}
+
+
+
+ +
+
+
+
+
+
+
+
{% trans 'Gender Data' %}
+
+
+
+ + + + + + + + + {% for label, value in gender_table %} + + + + + {% endfor %} + +
{% trans 'Gender' %}{% trans 'Views' %}
{{ label }}{{ value }}
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/users/templates/users/parts/dashboard_views_by_province.html b/users/templates/users/parts/dashboard_views_by_province.html new file mode 100644 index 0000000..da78f0b --- /dev/null +++ b/users/templates/users/parts/dashboard_views_by_province.html @@ -0,0 +1,81 @@ +{% extends 'users/types/user_admin.html' %} +{% load i18n %} + +{% block parts_content %} +
+ {% include 'users/parts/filter_form.html' %} + + + +
+
+
+
+
{% trans 'Views by Province (Top 10)' %}
+
+
+
+ +
+
+
+
+
+
+
+
{% trans 'Province Data' %}
+
+
+
+ + + + + + + + + {% for label, value in province_table %} + + + + + {% endfor %} + +
{% trans 'Province' %}{% trans 'Views' %}
{{ label }}{{ value }}
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/users/templates/users/parts/history.html b/users/templates/users/parts/history.html index 0ec0b64..7559471 100644 --- a/users/templates/users/parts/history.html +++ b/users/templates/users/parts/history.html @@ -1,20 +1,91 @@ {% extends 'users/types/user_client.html' %} {% load i18n %} {% load static %} + {% block extra_css %} {{ block.super }} {% endblock %} {% block parts_content %} -
- {% for movie in movies %} - {% include 'part/cardlite.html' with movie=movie %} - {% empty %} -
-
-

{% trans "No movies in history yet." %}

+
+
+
+
+ + {% trans "Filtres:" %} +
+ + + + + + {% if selected_platform or selected_date %} + + {% trans "Netejar filtres" %} + + {% endif %} +
- {% endfor %} + + {% if grouped_history %} + {% for group_name, items in grouped_history.items %} +
+

{{ group_name }}

+
+ {% for vis in items %} +
+
+ {% if vis.contingut.poster_path %} + {{ vis.contingut.titol }} + {% else %} +
{{ vis.contingut.titol|first|upper }}
+ {% endif %} +
+ +
+
{{ vis.contingut.titol }}
+
+ {{ vis.content_type }} + + {{ vis.api.name|default:vis.api.port }} + + + {{ vis.data_visualitzacio|date:"d M Y, H:i" }} + +
+
+
+ {% endfor %} +
+
+ {% endfor %} + + {% if page_obj.paginator.num_pages > 1 %} +
+ {% include 'paginator.html' with items=page_obj %} +
+ {% endif %} + {% else %} +
+
📺
+

{% trans "No s'han trobat visualitzacions amb aquests criteris." %}

+ {% if selected_platform or selected_date %} + {% trans "Tornar a veure-ho tot" %} + {% endif %} +
+ {% endif %}
{% endblock %} \ No newline at end of file diff --git a/users/templates/users/parts/system_logs.html b/users/templates/users/parts/system_logs.html new file mode 100644 index 0000000..e7e2100 --- /dev/null +++ b/users/templates/users/parts/system_logs.html @@ -0,0 +1,78 @@ +{% extends 'users/types/staff_admin.html' %} +{% load i18n %} + +{% block staff_admin_content %} +
+
+
+

{% trans "Logs de Sincronización" %}

+

{% trans "Registro histórico del Scheduler con las APIs externas." %}

+
+
+ +
+
+ + + + + + + + + + + {% for log in logs %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Fecha de Inicio" %}{% trans "Fecha Fin" %}{% trans "Estado" %}{% trans "Resumen" %}
+ {{ log.start_time|date:"d M Y, H:i:s" }} + + {% if log.end_time %} + {{ log.end_time|date:"d M Y, H:i:s" }} + {% else %} + {% trans "En curso..." %} + {% endif %} + + {% if log.status == 'SUCCESS' %} + + Éxito + + {% elif log.status == 'ERROR' %} + + Error + + {% else %} + + {{ log.status }} + + {% endif %} + + {% if log.summary %} + {{ log.summary|truncatechars:80 }} + {% else %} + - + {% endif %} +
+ {% trans "Todavía no hay ningún registro de sincronización." %} +
+
+
+ + {% if logs.has_other_pages %} +
+ {% include 'paginator.html' with items=logs %} +
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/users/templates/users/profile/user_profile.html b/users/templates/users/profile/user_profile.html index 2506275..93a8638 100644 --- a/users/templates/users/profile/user_profile.html +++ b/users/templates/users/profile/user_profile.html @@ -48,11 +48,71 @@
- - {{ form.location }} + + {{ form.gender }} +
+ +
+ + {{ form.age_range }} +
+ +
+ + +
+ +
+ +
+ + {% endblock %} \ No newline at end of file diff --git a/users/templates/users/settings/user_settings.html b/users/templates/users/settings/user_settings.html deleted file mode 100644 index fabc638..0000000 --- a/users/templates/users/settings/user_settings.html +++ /dev/null @@ -1,58 +0,0 @@ -{% extends 'users/base_profile.html' %} -{% load static %} -{% load i18n %} - -{% block content_user_setings %} - {% if user.type == 'Consumer' %} -
- - -
- -
-
{% trans "Bio" %}
-

- {% if user.bio %} - {{ user.bio }} - {% else %} - {% trans "Write a few words about you" %} - {% endif %} -

-
- -
-
{% trans "Member since" %}
-

{{ user.date_joined|date:"d/m/Y" }}

-
- -
-
{% trans "Last access" %}
-

{{ user.last_login|date:"d/m/Y" }}

-
- -
-
{% trans "Location" %}
-

- {% if user.location %} - {{ user.location }} - {% else %} - {% trans "Add your location" %} - {% endif %} -

-
- -
- - {% endif %} - - - {% if user.type == 'Consumer' %} -
- {% else %} -
- {% endif %} - {% block user_content %}{% endblock %} -
- -
-{% endblock %} \ No newline at end of file diff --git a/users/templates/users/types/staff_admin.html b/users/templates/users/types/staff_admin.html index 713f841..6136a70 100644 --- a/users/templates/users/types/staff_admin.html +++ b/users/templates/users/types/staff_admin.html @@ -1,4 +1,4 @@ -{% extends 'users/settings/user_settings.html' %} +{% extends 'users/base_profile.html' %} {% load i18n %} {% block content_staff_admin %} @@ -12,10 +12,14 @@ {% trans 'Carteleras' %} + -
+
{% block staff_admin_content %}{% endblock %}
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/users/templates/users/types/user_admin.html b/users/templates/users/types/user_admin.html index 5883179..0463fd8 100644 --- a/users/templates/users/types/user_admin.html +++ b/users/templates/users/types/user_admin.html @@ -1,4 +1,4 @@ -{% extends 'users/settings/user_settings.html' %} +{% extends 'users/base_profile.html' %} {% load i18n %} {% block content_admin %} @@ -23,6 +23,10 @@ {% trans 'Directors' %} + diff --git a/users/templates/users/types/user_client.html b/users/templates/users/types/user_client.html index dfce223..11ddd18 100644 --- a/users/templates/users/types/user_client.html +++ b/users/templates/users/types/user_client.html @@ -1,31 +1,97 @@ -{% extends 'users/settings/user_settings.html' %} +{% extends 'users/base_profile.html' %} {% load i18n %} {% block content_user %} - - - - -
- {% block parts_content %}{% endblock %} +
+ +
+ +
+
{% trans "Bio" %}
+

+ {% if user.bio %} + {{ user.bio }} + {% else %} + {% trans "Write a few words about you" %} + {% endif %} +

+
+ +
+
{% trans "Member since" %}
+

{{ user.date_joined|date:"d/m/Y" }}

+
+ +
+
{% trans "Last access" %}
+

{{ user.last_login|date:"d/m/Y" }}

+
+ +
+
{% trans "Gender" %}
+

+ {% if user.gender %} + {{ user.get_gender_display }} + {% else %} + {% trans "Not specified" %} + {% endif %} +

+
+ +
+
{% trans "Age range" %}
+

+ {% if user.age_range %} + {{ user.get_age_range_display }} + {% else %} + {% trans "Not specified" %} + {% endif %} +

+
+ +
+
{% trans "Location" %}
+

+ {% if user.municipality %} + {{ user.municipality.name }} ({{ user.municipality.province.name }}) + {% else %} + {% trans "Not specified" %} + {% endif %} +

+
+
-{% endblock %} \ No newline at end of file +
+ + + +
+ {% block parts_content %}{% endblock %} +
+ +
+ +
+ +{% endblock %} diff --git a/users/urls.py b/users/urls.py index 6c7af8c..5d84b71 100644 --- a/users/urls.py +++ b/users/urls.py @@ -1,5 +1,6 @@ from django.urls import path from . import views +from web_app.views import system_logs urlpatterns = [ # Staff Admin URLs @@ -10,7 +11,7 @@ path('api/update-role/', views.update_user_role, name='update_user_role'), path('staff-admin/cartelleres/', views.gestion_cartelleres, name='gestion_cartelleres'), path('staff-admin/cartelleres/editar//', views.editar_cartellera, name='editar_cartellera'), - + path('staff-admin/logs/', system_logs, name='system_logs'), # User URLs path('profile/', views.user_profile, name='user_profile'), path('history/', views.history, name='history'), @@ -23,4 +24,9 @@ path('admin/dashboard/genres/', views.admin_dashboard_genres, name='admin_dashboard_genres'), path('admin/dashboard/age-ratings/', views.admin_dashboard_age_ratings, name='admin_dashboard_age_ratings'), path('admin/dashboard/directors/', views.admin_dashboard_directors, name='admin_dashboard_directors'), + path('admin/dashboard/demographics/', views.admin_dashboard_demographics, name='admin_dashboard_demographics'), + path('admin/dashboard/demographics/gender/', views.admin_dashboard_views_by_gender, name='admin_dashboard_views_by_gender'), + path('admin/dashboard/demographics/age/', views.admin_dashboard_views_by_age, name='admin_dashboard_views_by_age'), + path('admin/dashboard/demographics/province/', views.admin_dashboard_views_by_province, name='admin_dashboard_views_by_province'), + path('admin/dashboard/demographics/cross-tab/', views.admin_dashboard_cross_tab, name='admin_dashboard_cross_tab'), ] \ No newline at end of file diff --git a/users/views.py b/users/views.py index 4747a35..551bfaf 100644 --- a/users/views.py +++ b/users/views.py @@ -9,6 +9,7 @@ from django.utils.crypto import get_random_string from django.contrib.sessions.models import Session from django.utils import timezone +from datetime import timedelta import json import gettext @@ -21,13 +22,17 @@ _ = gettext.gettext from web_app.forms import CustomUserChangeForm, CustomUserAdminCreationForm -from web_app.models import CustomUser, Movie, Series, UserProfile, API, Visualitzacio, UserType, RoleAuditLog +from web_app.models import CustomUser, Movie, Series, UserProfile, API, Visualitzacio, UserType, RoleAuditLog, Province, Municipality from .services import ( get_content_analytics, format_analytics_for_csv, get_genre_distribution, get_age_rating_distribution, get_director_top_list, + get_views_by_gender, + get_views_by_age_range, + get_views_by_province, + get_views_by_gender_age, PII_COLUMNS, validate_gdpr_compliance, ) @@ -80,17 +85,85 @@ def user_profile(request): else: form = CustomUserChangeForm(instance=request.user) - return render(request, 'users/profile/user_profile.html', {'form': form}) + current_province_id = None + if request.user.municipality: + current_province_id = request.user.municipality.province_id + + return render(request, 'users/profile/user_profile.html', { + 'form': form, + 'provinces': Province.objects.all(), + 'current_province_id': current_province_id, + 'current_municipality_id': request.user.municipality_id, + }) @login_required(login_url='login') @user_passes_test(is_consumer) def history(request): - # OPTIMIZACIÓN: Buscamos en el modelo real de visualizaciones y hacemos Eager Loading - visualizaciones = Visualitzacio.objects.filter(user=request.user).select_related( - 'contingut__movie', 'contingut__series' - ).prefetch_related('contingut__apis').order_by('-data_visualitzacio') - - return render(request, 'users/parts/history.html', {'visualizaciones': visualizaciones}) + user = request.user + visualizaciones = Visualitzacio.objects.filter(user=user).select_related( + 'contingut__movie', 'contingut__series', 'api' + ).order_by('-data_visualitzacio') + + platform_id = request.GET.get('platform') + if platform_id: + visualizaciones = visualizaciones.filter(api_id=platform_id) + + date_filter = request.GET.get('date') + now = timezone.now() + if date_filter == 'today': + visualizaciones = visualizaciones.filter(data_visualitzacio__date=now.date()) + elif date_filter == 'week': + visualizaciones = visualizaciones.filter(data_visualitzacio__gte=now - timedelta(days=7)) + elif date_filter == 'month': + visualizaciones = visualizaciones.filter(data_visualitzacio__gte=now - timedelta(days=30)) + + paginator = Paginator(visualizaciones, 20) + page_number = request.GET.get('page') + page_obj = paginator.get_page(page_number) + + grouped_history = { + 'Avui': [], + 'Aquesta setmana': [], + 'Aquest mes': [], + 'Més antic': [] + } + + today = now.date() + week_ago = (now - timedelta(days=7)).date() + month_ago = (now - timedelta(days=30)).date() + + for vis in page_obj: + vis_date = vis.data_visualitzacio.date() + + if hasattr(vis.contingut, 'movie'): + vis.content_type = 'Movie' + elif hasattr(vis.contingut, 'series'): + vis.content_type = 'Series' + else: + vis.content_type = 'Desconegut' + + if vis_date == today: + grouped_history['Avui'].append(vis) + elif vis_date >= week_ago: + grouped_history['Aquesta setmana'].append(vis) + elif vis_date >= month_ago: + grouped_history['Aquest mes'].append(vis) + else: + grouped_history['Més antic'].append(vis) + + grouped_history = {k: v for k, v in grouped_history.items() if v} + + platforms = API.objects.all() + + context = { + 'page_obj': page_obj, + 'grouped_history': grouped_history, + 'platforms': platforms, + 'selected_platform': platform_id, + 'selected_date': date_filter, + } + + return render(request, 'users/parts/history.html', context) @login_required(login_url='login') @user_passes_test(is_consumer) @@ -217,14 +290,17 @@ def crear_usuario_admin(request): def export_analytics_csv(request): start_date = request.GET.get('start') end_date = request.GET.get('end') - report_type = request.GET.get('type', 'internal') + report_type = request.GET.get('type', 'internal') + + if start_date == 'None': start_date = None + if end_date == 'None': end_date = None platform_ids = get_active_platform_ids(request.user) raw_data = get_content_analytics( start_date=start_date, end_date=end_date, - plataform_id=platform_ids # Ahora pasamos una lista de IDs segura + platform_ids=platform_ids ) is_b2b = (report_type == 'b2b') @@ -234,7 +310,7 @@ def export_analytics_csv(request): response['Content-Disposition'] = 'attachment; filename="analytics_report.csv"' writer = csv.writer(response) - writer.writerow(['Title', 'Genre', 'Age Rating', 'Year', 'Director', 'Platform', 'Total Views', 'Total Favorites'])#TODO translate this + writer.writerow(['Title', 'Genre', 'Age Rating', 'Year', 'Director', 'Platform', 'Total Visualizations', 'Total Favorites'])#TODO translate this for item in clean_data: writer.writerow([ @@ -326,6 +402,110 @@ def admin_dashboard_directors(request): 'platforms': API.objects.all() }) +@login_required(login_url='login') +@user_passes_test(is_admin_or_staff) +def admin_dashboard_demographics(request): + start_date = request.GET.get('start') + end_date = request.GET.get('end') + platform_ids = get_active_platform_ids(request.user) + + gender_stats = get_views_by_gender(start_date, end_date, platform_ids) + age_stats = get_views_by_age_range(start_date, end_date, platform_ids) + province_stats = get_views_by_province(start_date, end_date, platform_ids) + cross_stats = get_views_by_gender_age(start_date, end_date, platform_ids) + + gender_table = list(zip(gender_stats['labels'], gender_stats['values'])) + age_table = list(zip(age_stats['labels'], age_stats['values'])) + province_table = list(zip(province_stats['labels'], province_stats['values'])) + + return render(request, 'users/parts/dashboard_demographics.html', { + 'gender_stats': gender_stats, + 'age_stats': age_stats, + 'province_stats': province_stats, + 'cross_stats': cross_stats, + 'gender_table': gender_table, + 'age_table': age_table, + 'province_table': province_table, + 'filters': {'start': start_date, 'end': end_date}, + 'platforms': API.objects.all(), + }) + +@login_required(login_url='login') +@user_passes_test(is_admin_or_staff) +def admin_dashboard_views_by_gender(request): + start_date = request.GET.get('start') + end_date = request.GET.get('end') + platform_ids = get_active_platform_ids(request.user) + + gender_stats = get_views_by_gender(start_date, end_date, platform_ids) + gender_table = list(zip(gender_stats['labels'], gender_stats['values'])) + + return render(request, 'users/parts/dashboard_views_by_gender.html', { + 'gender_stats': gender_stats, + 'gender_table': gender_table, + 'filters': {'start': start_date, 'end': end_date}, + 'platforms': API.objects.all(), + }) + +@login_required(login_url='login') +@user_passes_test(is_admin_or_staff) +def admin_dashboard_views_by_age(request): + start_date = request.GET.get('start') + end_date = request.GET.get('end') + platform_ids = get_active_platform_ids(request.user) + + age_stats = get_views_by_age_range(start_date, end_date, platform_ids) + age_table = list(zip(age_stats['labels'], age_stats['values'])) + + return render(request, 'users/parts/dashboard_views_by_age.html', { + 'age_stats': age_stats, + 'age_table': age_table, + 'filters': {'start': start_date, 'end': end_date}, + 'platforms': API.objects.all(), + }) + +@login_required(login_url='login') +@user_passes_test(is_admin_or_staff) +def admin_dashboard_views_by_province(request): + start_date = request.GET.get('start') + end_date = request.GET.get('end') + platform_ids = get_active_platform_ids(request.user) + + province_stats = get_views_by_province(start_date, end_date, platform_ids) + province_table = list(zip(province_stats['labels'], province_stats['values'])) + + return render(request, 'users/parts/dashboard_views_by_province.html', { + 'province_stats': province_stats, + 'province_table': province_table, + 'filters': {'start': start_date, 'end': end_date}, + 'platforms': API.objects.all(), + }) + +@login_required(login_url='login') +@user_passes_test(is_admin_or_staff) +def admin_dashboard_cross_tab(request): + start_date = request.GET.get('start') + end_date = request.GET.get('end') + platform_ids = get_active_platform_ids(request.user) + + cross_stats = get_views_by_gender_age(start_date, end_date, platform_ids) + + cross_table_headers = [d['label'] for d in cross_stats['datasets']] + cross_table_rows = [] + for i, label in enumerate(cross_stats['labels']): + row = [label] + for dataset in cross_stats['datasets']: + row.append(dataset['data'][i] if i < len(dataset['data']) else 0) + cross_table_rows.append(row) + + return render(request, 'users/parts/dashboard_cross_tab.html', { + 'cross_stats': cross_stats, + 'cross_table_headers': cross_table_headers, + 'cross_table_rows': cross_table_rows, + 'filters': {'start': start_date, 'end': end_date}, + 'platforms': API.objects.all(), + }) + @user_passes_test(lambda u: u.is_superuser) def update_user_role(request): if request.method == 'POST': @@ -420,3 +600,4 @@ def editar_cartellera(request, contingut_id): return render(request, 'users/parts/editar_cartellera.html', { 'contingut': contingut, }) + diff --git a/web_app/admin.py b/web_app/admin.py index 9203990..4d6c700 100644 --- a/web_app/admin.py +++ b/web_app/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin from django.utils.safestring import mark_safe -from .models import SyncLog, API, Contingut, Movie, Series, Director, Genre, AgeRating, CustomUser, UserProfile, Valoracio, Preferits, RoleAuditLog +from .models import SyncLog, API, Contingut, Movie, Series, Director, Genre, AgeRating, CustomUser, UserProfile, Valoracio, Preferits, RoleAuditLog, Province, Municipality @admin.register(API) @@ -136,4 +136,15 @@ class RoleAuditLogAdmin(admin.ModelAdmin): list_display = ('timestamp', 'admin', 'affected_user', 'old_role', 'new_role') list_filter = ('timestamp', 'old_role', 'new_role') search_fields = ('admin__username', 'affected_user__username') - readonly_fields = ('admin', 'affected_user', 'old_role', 'new_role', 'timestamp') \ No newline at end of file + readonly_fields = ('admin', 'affected_user', 'old_role', 'new_role', 'timestamp') + +@admin.register(Province) +class ProvinceAdmin(admin.ModelAdmin): + list_display = ('ine_code', 'name') + search_fields = ('name',) + +@admin.register(Municipality) +class MunicipalityAdmin(admin.ModelAdmin): + list_display = ('ine_code', 'name', 'province') + list_filter = ('province',) + search_fields = ('name',) \ No newline at end of file diff --git a/web_app/apps.py b/web_app/apps.py index c071959..a9bd4d5 100644 --- a/web_app/apps.py +++ b/web_app/apps.py @@ -42,7 +42,6 @@ def create_default_user(sender, **kwargs): username = 'admin' email = 'admin@admin.com' password = 'adminpassword' - if not User.objects.filter(username=username).exists(): User.objects.create_superuser( username=username, diff --git a/web_app/forms.py b/web_app/forms.py index 5a49b38..a2f4bbb 100644 --- a/web_app/forms.py +++ b/web_app/forms.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxLengthValidator -from .models import CustomUser, API, UserType +from .models import CustomUser, API, UserType, Gender, AgeRange, Province, Municipality class CustomUserCreationForm(forms.ModelForm): """ @@ -45,9 +45,29 @@ class CustomUserCreationForm(forms.ModelForm): label=_("Select your platforms") ) + gender = forms.ChoiceField( + choices=Gender.choices, + label=_("Gender"), + required=True, + ) + + age_range = forms.ChoiceField( + choices=AgeRange.choices, + label=_("Age Range"), + required=True, + ) + + province = forms.ModelChoiceField( + queryset=Province.objects.all(), + label=_("Province"), + required=True, + ) + + class Meta: model = CustomUser - fields = ('username', 'email', 'password', 'password2', 'platforms') + fields = ('username', 'email', 'password', 'password2', 'platforms', + 'gender', 'age_range', 'province', 'municipality') def clean_password2(self): """ @@ -59,20 +79,72 @@ def clean_password2(self): if password is not None and password != password2: raise forms.ValidationError("Las contraseñas no coinciden") return password2 + + def clean(self): + cleaned_data = super().clean() + province = cleaned_data.get('province') + municipality_id = self.data.get('municipality') + + if province and municipality_id: + try: + municipality = Municipality.objects.get(id=municipality_id, province=province) + cleaned_data['municipality'] = municipality + except Municipality.DoesNotExist: + self.add_error('municipality', _("The selected municipality does not belong to the selected province.")) + elif province: + self.add_error('municipality', _("You must select a municipality.")) + + return cleaned_data + def save(self, commit=True): user = super().save(commit=False) user.set_password(self.cleaned_data['password']) + user.gender = self.cleaned_data.get('gender') + user.age_range = self.cleaned_data.get('age_range') + user.municipality = self.cleaned_data.get('municipality') + if commit: user.save() return user class CustomUserChangeForm(UserChangeForm): + province = forms.ModelChoiceField( + queryset=Province.objects.all(), + label=_("Province"), + required=False, + ) + class Meta: model = CustomUser - fields = ['username', 'first_name', 'last_name', 'email', 'avatar', 'bio', 'location', 'type'] + fields = [ + 'username', 'first_name', 'last_name', 'email', 'avatar', 'bio', + 'type', 'gender', 'age_range', 'municipality', + ] help_texts = {field: '' for field in fields} + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance and self.instance.municipality: + self.fields['province'].initial = self.instance.municipality.province + self.fields['province'].widget.attrs['data-current-province'] = self.instance.municipality.province_id + + def clean(self): + cleaned_data = super().clean() + province = cleaned_data.get('province') + municipality_id = self.data.get('municipality') + + if province and municipality_id: + try: + municipality = Municipality.objects.get(id=municipality_id, province=province) + cleaned_data['municipality'] = municipality + except Municipality.DoesNotExist: + self.add_error('municipality', _("The selected municipality does not belong to the selected province.")) + elif province: + self.add_error('municipality', _("You must select a municipality.")) + + return cleaned_data + def clean_avatar(self): """ Performs custom validation on the uploaded avatar file to strictly enforce diff --git a/web_app/management/__init__.py b/web_app/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web_app/management/commands/__init__.py b/web_app/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/web_app/management/commands/load_municipalities.py b/web_app/management/commands/load_municipalities.py new file mode 100644 index 0000000..eefe039 --- /dev/null +++ b/web_app/management/commands/load_municipalities.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path + +from django.core.management.base import BaseCommand + +from web_app.models import Province, Municipality + + +class Command(BaseCommand): + help = "Loads provinces and municipalities from a JSON file into the database." + + def add_arguments(self, parser): + parser.add_argument( + '--source', + type=str, + default=None, + help='Path to the JSON data file. Defaults to data/municipalities_spain.json', + ) + + def handle(self, *args, **options): + source = options.get('source') + if not source: + source = Path(__file__).resolve().parents[3] / 'data' / 'municipalities_spain.json' + + source_path = Path(source) + + if not source_path.exists(): + self.stderr.write(self.style.ERROR(f"File not found: {source_path}")) + return + + with open(source_path, encoding='utf-8') as f: + data = json.load(f) + + provinces_created = 0 + municipalities_created = 0 + + for province_data in data: + province, created = Province.objects.update_or_create( + ine_code=province_data['ine_code'], + defaults={'name': province_data['name']}, + ) + if created: + provinces_created += 1 + self.stdout.write(f" Created province: {province.name}") + + for municipality_data in province_data.get('municipalities', []): + municipality, created = Municipality.objects.update_or_create( + ine_code=municipality_data['ine_code'], + defaults={ + 'name': municipality_data['name'], + 'province': province, + }, + ) + if created: + municipalities_created += 1 + + self.stdout.write(self.style.SUCCESS( + f"Done. {provinces_created} provinces and {municipalities_created} municipalities loaded." + )) diff --git a/web_app/migrations/0004_municipality_province_remove_customuser_location_and_more.py b/web_app/migrations/0004_municipality_province_remove_customuser_location_and_more.py new file mode 100644 index 0000000..52b41c2 --- /dev/null +++ b/web_app/migrations/0004_municipality_province_remove_customuser_location_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 6.0.3 on 2026-06-09 09:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('web_app', '0003_roleauditlog'), + ] + + operations = [ + migrations.CreateModel( + name='Municipality', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ine_code', models.CharField(max_length=5, unique=True)), + ('name', models.CharField(max_length=255)), + ], + options={ + 'verbose_name': 'Municipio', + 'verbose_name_plural': 'Municipios', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Province', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('ine_code', models.CharField(max_length=2, unique=True)), + ('name', models.CharField(max_length=255)), + ], + options={ + 'verbose_name': 'Provincia', + 'verbose_name_plural': 'Provincias', + 'ordering': ['name'], + }, + ), + migrations.RemoveField( + model_name='customuser', + name='location', + ), + migrations.AddField( + model_name='customuser', + name='age_range', + field=models.CharField(blank=True, choices=[('<18', 'Under 18'), ('18-30', '18-30'), ('31-50', '31-50'), ('>50', 'Over 50')], max_length=10, null=True), + ), + migrations.AddField( + model_name='customuser', + name='gender', + field=models.CharField(blank=True, choices=[('Male', 'Male'), ('Female', 'Female'), ('Non-binary', 'Non-binary'), ('Other', 'Other')], max_length=50, null=True), + ), + migrations.AddField( + model_name='customuser', + name='municipality', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='web_app.municipality'), + ), + migrations.AddField( + model_name='municipality', + name='province', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='municipalities', to='web_app.province'), + ), + ] diff --git a/web_app/models.py b/web_app/models.py index 2f3c49a..83776c3 100644 --- a/web_app/models.py +++ b/web_app/models.py @@ -9,6 +9,44 @@ class UserType(models.TextChoices): ADMIN = 'Admin', 'Admin' CONSUMER = 'Consumer', 'Consumer' + +class Gender(models.TextChoices): + MALE = 'Male', 'Male' + FEMALE = 'Female', 'Female' + NON_BINARY = 'Non-binary', 'Non-binary' + OTHER = 'Other', 'Other' + +class AgeRange(models.TextChoices): + UNDER_18 = '<18', 'Under 18' + BETWEEN_18_30 = '18-30', '18-30' + BETWEEN_31_50 = '31-50', '31-50' + OVER_50 = '>50', 'Over 50' + +class Province(models.Model): + ine_code = models.CharField(max_length=2, unique=True) + name = models.CharField(max_length=255) + + class Meta: + verbose_name = 'Provincia' + verbose_name_plural = 'Provincias' + ordering = ['name'] + def __str__(self): + return self.name + +class Municipality(models.Model): + ine_code = models.CharField(max_length=5, unique=True) + name = models.CharField(max_length=255) + province = models.ForeignKey(Province, on_delete=models.CASCADE, related_name='municipalities') + + class Meta: + verbose_name = 'Municipio' + verbose_name_plural = 'Municipios' + ordering = ['name'] + + def __str__(self): + return self.name + + class API(models.Model): port = models.IntegerField(unique=True) name = models.CharField(max_length=100, blank=True, default='') @@ -130,7 +168,7 @@ def get_similar_by_genre(self, limit=4): return Movie.objects.filter( contingut__genere=self.contingut.genere - ).exclude(id=self.id).order_by('?')[:limit] + ).exclude(id=self.id).distinct().order_by('?')[:limit] class Series(models.Model): contingut = models.OneToOneField(Contingut, on_delete=models.CASCADE, related_name='series') @@ -187,6 +225,14 @@ def total_seasons(self): def poster_path(self): return self.contingut.poster_path + def get_similar_by_genre(self, limit=4): + if not self.contingut.genere: + return Series.objects.none() + + return Series.objects.filter( + contingut__genere=self.contingut.genere + ).exclude(id=self.id).distinct().order_by('?')[:limit] + class CustomUser(AbstractUser): """ @@ -195,11 +241,29 @@ class CustomUser(AbstractUser): """ avatar = models.ImageField(upload_to='avatars/', null=True, blank=True) bio = models.TextField(blank=True, null=True) - location = models.CharField(max_length=255, blank=True, null=True) type = models.CharField( choices=UserType.choices, default=UserType.CONSUMER, ) + gender = models.CharField( + choices=Gender.choices, + max_length=50, + blank=True, + null=True + ) + age_range = models.CharField( + choices=AgeRange.choices, + max_length=10, + blank=True, + null=True + ) + municipality = models.ForeignKey( + Municipality, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='users' + ) subscriptions = models.ManyToManyField('API', blank=True, related_name='subscribed_users') def __str__(self): diff --git a/web_app/urls.py b/web_app/urls.py index 193488c..2fe6d87 100644 --- a/web_app/urls.py +++ b/web_app/urls.py @@ -27,6 +27,8 @@ path('terms-use/', views.terms_use, name='terms_use'), path('privacy-policy/', views.privacy_policy, name='privacy_policy'), + path('api/municipalities//', views.municipalities_by_province, name='municipalities_api'), + #path('user/profile', views.profile, name='profile'), #path('user/history', views.history, name='history'), diff --git a/web_app/views.py b/web_app/views.py index dde9023..9363e8e 100644 --- a/web_app/views.py +++ b/web_app/views.py @@ -3,8 +3,14 @@ from django.utils.crypto import get_random_string from django.shortcuts import render, redirect, get_object_or_404 from web_app.forms import CustomUserAdminCreationForm, CustomUserCreationForm -from web_app.models import AgeRating, API, CustomUser, Director, Genre, Movie, UserProfile, Series, Contingut, \ - AgeRating, Valoracio, Visualitzacio +from web_app.models import ( + API, Director, Genre, AgeRating, Contingut, + Movie, Series, CustomUser, UserProfile, + Preferits, Valoracio, Visualitzacio, SyncLog, + RoleAuditLog, + Province, Municipality, +) + from web_app import utils import json from itertools import chain @@ -89,7 +95,7 @@ def register_view(request): user.subscriptions.set(platforms) login(request, user) return redirect('home') - return render(request, 'identify/register.html', {'form': form}) + return render(request, 'identify/register.html', {'form': form, 'provinces': Province.objects.all()}) @login_required(login_url='login') def redirect_by_role(request): @@ -161,6 +167,7 @@ def series_detail(request, pk): 'reviews': reviews, 'avg_rating': review_stats['avg_rating'], 'total_reviews': review_stats['total_reviews'], + 'recommendations': series.get_similar_by_genre(limit=4) }) @@ -240,6 +247,7 @@ def terms_use(request): def privacy_policy(request): return render(request, 'footer_legal/privacy_policy.html') +@login_required def register_platform_click(request): if request.method == 'POST': try: @@ -247,22 +255,36 @@ def register_platform_click(request): contingut_id = data.get('contingut_id') api_id = data.get('api_id') - contingut = get_object_or_404(Contingut, id=contingut_id) - api = get_object_or_404(API, id=api_id) - - user = request.user if request.user.is_authenticated else None - Visualitzacio.objects.create( - user=user, - contingut=contingut, - api=api + # MAGIA DE DJANGO: Si existe, actualiza la fecha. Si no, lo crea. + vis, created = Visualitzacio.objects.update_or_create( + user=request.user, + contingut_id=contingut_id, + api_id=api_id, + defaults={'data_visualitzacio': timezone.now()} ) - total_clicks = Visualitzacio.objects.filter(contingut=contingut, api=api).count() - - return JsonResponse({'status': 'success', 'total_clicks': total_clicks}) - + return JsonResponse({ + 'status': 'success', + 'message': 'Historial actualizado correctamente' + }) except Exception as e: return JsonResponse({'status': 'error', 'message': str(e)}, status=400) return JsonResponse({'status': 'error', 'message': 'Método no permitido'}, status=405) +def municipalities_by_province(request, province_id): + municipalities = Municipality.objects.filter(province_id=province_id).values('id', 'name') + return JsonResponse(list(municipalities), safe=False) + +@user_passes_test(lambda u: u.is_superuser) +def system_logs(request): + if not request.user.is_authenticated: + return redirect('login') + + logs = SyncLog.objects.all().order_by('-start_time') + paginator = Paginator(logs, 20) + page_obj = paginator.get_page(request.GET.get('page', 1)) + + return render(request, 'users/parts/system_logs.html', { + 'logs': page_obj + })