Compare commits

..

35 commits

Author SHA1 Message Date
38e4ce2562 Ajout des partials _stats_head.html et _stats_toolbar.html pour harmoniser les styles et outils des pages de statistiques. 2025-12-16 15:16:18 +01:00
18b807bf5a Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 15:03:46 +01:00
d20302be0e Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 14:16:24 +01:00
7cf04968eb Ajout de la fonctionnalité d’activité en direct dans le tableau de bord des statistiques et mise à jour des templates, vues, URL, et styles associés. 2025-12-16 14:15:58 +01:00
a7b51e3a82 Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 13:55:46 +01:00
8f0fad45be Affichage de la progression utilisateur dans les cours suivis et amélioration des templates associés. 2025-12-16 13:55:14 +01:00
81b42b8b4a Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 13:23:58 +01:00
afa673ccd9 Tri des articles par date de création décroissante dans le context_processor. 2025-12-16 13:23:15 +01:00
5fd06f5ae1 Ajout de la feuille de style christmas.css pour les décorations festives et animations de neige, chargées conditionnellement en décembre. 2025-12-16 13:14:15 +01:00
5a241e394b Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 13:12:00 +01:00
91f7f79546 Ajout des décorations et animations de neige pour les fêtes de fin d'année, chargées conditionnellement en décembre. 2025-12-16 13:10:43 +01:00
e1f8a23f3d Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:53:26 +01:00
1b0ccc54a2 Ajout du template stats_charts.html pour afficher des graphiques statistiques interactifs avec Chart.js. 2025-12-16 10:52:55 +01:00
f9e2df559c Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:48:48 +01:00
7869abf441 Ajout de la vue stats_charts avec graphiques détaillés pour les superadministrateurs, mise à jour des templates et des routes associées, et configuration supplémentaire pour l'email backend. 2025-12-16 10:48:12 +01:00
083af6f9d4 Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:28:55 +01:00
6e8a2bc287 Ajout du suivi des visites : modèle Visit, middleware de tracking, mises à jour des vues et du tableau de bord statistiques. 2025-12-16 10:28:20 +01:00
bec74976ba Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:20:26 +01:00
b3b18bd15a Mise à jour de la version applicative dans VERSION.txt. 2025-12-16 10:20:12 +01:00
fc4939577a Ajout de la gestion de l'activation des comptes utilisateur par e-mail, des validations pour les pseudos existants, et configuration par défaut de l'e-mail backend. 2025-12-16 10:19:47 +01:00
ca0211e841 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 23:03:16 +01:00
ab8307b272 Ajout du template stats_dashboard.html pour afficher le tableau de bord des statistiques, avec styles, graphiques Chart.js, tableaux dynamiques et gestion du contexte. 2025-12-15 23:02:43 +01:00
2c715a3af4 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 22:55:01 +01:00
e44e31bfb9 Ajout du tableau de bord statistiques pour les superadministrateurs, avec cache et graphiques JSON, et mise à jour des routes pour inclure la vue correspondante. 2025-12-15 22:54:27 +01:00
82c2e234e3 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 22:34:39 +01:00
ac8ef6894d Ajout des fonctionnalités de gestion de progression des cours : vue dédiée pour le toggle des leçons, mise à jour des templates pour afficher la progression, intégration des routes Ajax, styles associés, et ajustements des vues et modèles pour gérer les données utilisateur. 2025-12-15 22:34:02 +01:00
609745a723 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 21:30:13 +01:00
4a48425374 Ajout d'un champ description au modèle Post avec migration associée, mise à jour des templates pour utiliser ce champ, et amélioration du formatage des commentaires Markdown avec gestion des titres typographiques. 2025-12-15 21:29:26 +01:00
43af8bd0d8 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 20:59:43 +01:00
c1749068af Ajout des fonctionnalités de blog : modèles, migrations, vues, templates, contexte et styles. 2025-12-15 20:58:25 +01:00
3e44013132 Ajout des applications blog et progression avec modèles, vues, URLs et intégration dans le sitemap et les configurations du projet. 2025-12-15 16:02:34 +01:00
45d2cb66f0 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 14:50:33 +01:00
2bfab05b49 Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 14:44:27 +01:00
6c7f91c72f Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 14:29:39 +01:00
84c94e28de Mise à jour de la version applicative dans VERSION.txt. 2025-12-15 14:27:24 +01:00
58 changed files with 1977 additions and 76 deletions

View file

@ -1 +1 @@
1.0.3 (e79ffee)
1.3.8 (7cf0496)

0
blog/__init__.py Normal file
View file

8
blog/admin.py Normal file
View file

@ -0,0 +1,8 @@
from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ('name', 'tags', 'slug', 'created_at')
search_fields = ('name', 'tags')
prepopulated_fields = {"slug": ("name",)}

5
blog/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BlogConfig(AppConfig):
name = 'blog'

View file

@ -0,0 +1,5 @@
from .models import Post
def posts_list(request):
posts = Post.objects.all().order_by('-created_at')
return {'posts': posts}

View file

@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2025-12-15 14:58
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Post',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('tags', models.CharField(max_length=200)),
('slug', models.SlugField()),
('content', models.TextField()),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Article',
'verbose_name_plural': 'Articles',
},
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-15 19:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='post',
name='enable',
field=models.BooleanField(default=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-15 20:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('blog', '0002_post_enable'),
]
operations = [
migrations.AddField(
model_name='post',
name='description',
field=models.TextField(default='Courte description'),
),
]

View file

18
blog/models.py Normal file
View file

@ -0,0 +1,18 @@
from django.db import models
class Post(models.Model):
name = models.CharField(max_length=200)
tags = models.CharField(max_length=200)
slug = models.SlugField()
description = models.TextField(default="Courte description")
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
enable = models.BooleanField(default=True)
class Meta:
verbose_name = "Article"
verbose_name_plural = "Articles"
def __str__(self):
return self.name

3
blog/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

8
blog/urls.py Normal file
View file

@ -0,0 +1,8 @@
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
path('', views.blog_home, name='blog'),
path('<str:slug>/', views.blog_view_post, name='post_detail'),
]

11
blog/views.py Normal file
View file

@ -0,0 +1,11 @@
from django.shortcuts import render
from blog.models import Post
def blog_home(request):
return render(request, 'blog/home.html')
def blog_view_post(request, slug):
post = Post.objects.get(slug=slug)
return render(request, 'blog/details.html', {'post': post})

View file

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import SiteSettings
from .models import SiteSettings, Visit
@admin.register(SiteSettings)
class SiteSettingsAdmin(admin.ModelAdmin):
@ -23,4 +23,14 @@ class SiteSettingsAdmin(admin.ModelAdmin):
('Contact', {
'fields': ('contact_email',)
}),
('Blog', {
'fields': ('blog_title', 'blog_description')
}),
)
@admin.register(Visit)
class VisitAdmin(admin.ModelAdmin):
list_display = ("date", "visitor_id", "user", "source", "country", "first_seen", "last_seen")
list_filter = ("date", "country", "source")
search_fields = ("visitor_id", "referrer", "utm_source", "utm_medium", "utm_campaign")

121
core/middleware.py Normal file
View file

@ -0,0 +1,121 @@
import uuid
from urllib.parse import urlparse
from django.utils import timezone
from .models import Visit
class VisitTrackingMiddleware:
"""Middleware très léger pour enregistrer des statistiques de visites.
- Assigne un cookie visiteur persistant (vid) si absent
- Enregistre/Met à jour une ligne Visit par visiteur et par jour
- Capture la source (UTM/referrer) et le pays si disponible via headers
"""
COOKIE_NAME = 'vid'
COOKIE_MAX_AGE = 60 * 60 * 24 * 365 * 2 # 2 ans
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
vid = request.COOKIES.get(self.COOKIE_NAME)
if not vid:
vid = uuid.uuid4().hex
request.visitor_id = vid
# Enregistrer la visite (agrégée par jour)
try:
self._track(request, vid)
except Exception:
# On ne casse jamais la requête pour des stats
pass
response = self.get_response(request)
# S'assurer que le cookie est posé
if request.COOKIES.get(self.COOKIE_NAME) != vid:
response.set_cookie(
self.COOKIE_NAME,
vid,
max_age=self.COOKIE_MAX_AGE,
httponly=True,
samesite='Lax',
)
return response
def _track(self, request, vid):
# On ignore l'admin et les assets statiques
path = request.path
if path.startswith('/admin') or path.startswith('/static') or path.startswith('/staticfiles'):
return
now = timezone.now()
date = now.date()
ref = request.META.get('HTTP_REFERER', '')[:512]
utm_source = request.GET.get('utm_source', '')[:100]
utm_medium = request.GET.get('utm_medium', '')[:100]
utm_campaign = request.GET.get('utm_campaign', '')[:150]
# Déterminer source
source = ''
if utm_source:
source = utm_source
elif ref:
try:
netloc = urlparse(ref).netloc
source = netloc
except Exception:
source = ref[:150]
# Déterminer pays via en-têtes si fournis par proxy/CDN
country = (
request.META.get('HTTP_CF_IPCOUNTRY')
or request.META.get('HTTP_X_APPENGINE_COUNTRY')
or request.META.get('HTTP_X_COUNTRY')
or ''
)[:64]
became_user_at = now if request.user.is_authenticated else None
visit, created = Visit.objects.get_or_create(
visitor_id=vid,
date=date,
defaults={
'path': path[:512],
'referrer': ref,
'utm_source': utm_source,
'utm_medium': utm_medium,
'utm_campaign': utm_campaign,
'source': source,
'country': country,
'first_seen': now,
'last_seen': now,
'user': request.user if request.user.is_authenticated else None,
'became_user_at': became_user_at,
}
)
if not created:
# Mise à jour basique
dirty = False
if not visit.source and source:
visit.source = source
dirty = True
if not visit.country and country:
visit.country = country
dirty = True
if request.user.is_authenticated and visit.user_id is None:
visit.user = request.user
dirty = True
# Marquer la conversion si pas encore définie
if visit.became_user_at is None:
visit.became_user_at = now
# Mettre à jour la page courante et l'horodatage
if visit.path != path:
visit.path = path[:512]
dirty = True
visit.last_seen = now
dirty = True
if dirty:
visit.save(update_fields=['source', 'country', 'user', 'became_user_at', 'path', 'last_seen'])

View file

@ -0,0 +1,23 @@
# Generated by Django 6.0 on 2025-12-15 19:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='blog_description',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='sitesettings',
name='blog_title',
field=models.CharField(default='Mon Blog', max_length=200),
),
]

View file

@ -0,0 +1,23 @@
# Generated by Django 6.0 on 2025-12-15 19:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_sitesettings_blog_description_and_more'),
]
operations = [
migrations.AlterField(
model_name='sitesettings',
name='blog_description',
field=models.TextField(blank=True, default='Je documente la construction de PartirDeZero.com : mes choix techniques, mes bugs résolus et mes conseils pour lancer tes propres projets web. Apprends en regardant faire.'),
),
migrations.AlterField(
model_name='sitesettings',
name='blog_title',
field=models.CharField(default='Blog du développeur', max_length=200),
),
]

View file

@ -0,0 +1,41 @@
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('core', '0003_alter_sitesettings_blog_description_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Visit',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('visitor_id', models.CharField(db_index=True, max_length=64)),
('date', models.DateField(db_index=True)),
('first_seen', models.DateTimeField(default=django.utils.timezone.now)),
('last_seen', models.DateTimeField(default=django.utils.timezone.now)),
('path', models.CharField(blank=True, max_length=512)),
('referrer', models.CharField(blank=True, max_length=512)),
('utm_source', models.CharField(blank=True, max_length=100)),
('utm_medium', models.CharField(blank=True, max_length=100)),
('utm_campaign', models.CharField(blank=True, max_length=150)),
('source', models.CharField(blank=True, help_text='Domaine de provenance ou utm_source', max_length=150)),
('country', models.CharField(blank=True, max_length=64)),
('became_user_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='visits', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-date', '-last_seen'],
},
),
migrations.AlterUniqueTogether(
name='visit',
unique_together={('visitor_id', 'date')},
),
]

View file

@ -1,4 +1,6 @@
from django.db import models
from django.conf import settings
from django.utils import timezone
class SiteSettings(models.Model):
site_name = models.CharField(max_length=200, default="Mon Super Site")
@ -13,6 +15,10 @@ class SiteSettings(models.Model):
linkedin_url = models.URLField(blank=True)
github_url = models.URLField(blank=True)
# Blog
blog_title = models.CharField(max_length=200, default="Blog du développeur")
blog_description = models.TextField(blank=True, default="Je documente la construction de PartirDeZero.com : mes choix techniques, mes bugs résolus et mes conseils pour lancer tes propres projets web. Apprends en regardant faire.")
# L'astuce pour qu'il n'y ait qu'un seul réglage
def save(self, *args, **kwargs):
self.pk = 1 # On force l'ID à 1. Si tu sauvegardes, ça écrase l'existant.
@ -27,3 +33,35 @@ class SiteSettings(models.Model):
class Meta:
verbose_name = "Réglages du site"
verbose_name_plural = "Réglages du site"
class Visit(models.Model):
"""Enregistrement simplifié des visites (agrégées par jour et visiteur).
Objectif: fournir des stats de base sans dépendances externes.
"""
visitor_id = models.CharField(max_length=64, db_index=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='visits'
)
date = models.DateField(db_index=True)
first_seen = models.DateTimeField(default=timezone.now)
last_seen = models.DateTimeField(default=timezone.now)
path = models.CharField(max_length=512, blank=True)
referrer = models.CharField(max_length=512, blank=True)
utm_source = models.CharField(max_length=100, blank=True)
utm_medium = models.CharField(max_length=100, blank=True)
utm_campaign = models.CharField(max_length=150, blank=True)
source = models.CharField(max_length=150, blank=True, help_text="Domaine de provenance ou utm_source")
country = models.CharField(max_length=64, blank=True)
# Conversion: première fois où un visiteur devient utilisateur authentifié
became_user_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ('visitor_id', 'date')
ordering = ['-date', '-last_seen']
def __str__(self):
return f"{self.visitor_id} @ {self.date}"

View file

@ -0,0 +1,21 @@
# Generated by Django 6.0 on 2025-12-15 14:58
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('courses', '0003_comment_parent'),
]
operations = [
migrations.AlterModelOptions(
name='course',
options={'verbose_name': 'Cours', 'verbose_name_plural': 'Cours'},
),
migrations.AlterModelOptions(
name='lesson',
options={'verbose_name': 'Leçon', 'verbose_name_plural': 'Leçons'},
),
]

View file

@ -12,6 +12,10 @@ class Course(models.Model):
updated_at = models.DateTimeField(auto_now=True)
enable = models.BooleanField(default=True)
class Meta:
verbose_name = "Cours"
verbose_name_plural = "Cours"
def __str__(self):
return self.name
@ -40,6 +44,10 @@ class Lesson(models.Model):
is_premium = models.BooleanField(default=False)
order = models.PositiveIntegerField()
class Meta:
verbose_name = "Leçon"
verbose_name_plural = "Leçons"
def clean(self):
# Remplacer les chevrons <?php et ?> par leurs équivalents HTML
if self.content:

View file

@ -27,6 +27,19 @@ def _format_inline(text: str) -> str:
lambda m: f"<code class=\"comment-code\">{m.group(1)}</code>",
text,
)
# H1
text = re.sub(r"^# (.+)$", r"<h1>\1</h1>", text, flags=re.MULTILINE)
# H2
text = re.sub(r"^## (.+)$", r"<h2>\1</h2>", text, flags=re.MULTILINE)
# H3
text = re.sub(r"^### (.+)$", r"<h3>\1</h3>", text, flags=re.MULTILINE)
# H4
text = re.sub(r"^#### (.+)$", r"<h4>\1</h4>", text, flags=re.MULTILINE)
# H5
text = re.sub(r"^##### (.+)$", r"<h5>\1</h5>", text, flags=re.MULTILINE)
# H6
text = re.sub(r"^###### (.+)$", r"<h6>\1</h6>", text, flags=re.MULTILINE)
# bold **text**
text = re.sub(
r"\*\*([^*]+)\*\*",

View file

@ -3,13 +3,14 @@ from django.urls import reverse
from django.views import generic
from django.db.models import Prefetch
from .models import Course, Lesson, Module, Comment
from progression.models import Progression
from .forms import CommentForm
def list_courses(request):
courses = Course.objects.all()
return render(request, 'courses/list.html', {'courses': courses})
def show(request, course_id):
def show(request, course_name, course_id):
# Optimized course fetch with related author and profile (if present)
course = get_object_or_404(
Course.objects.select_related('author', 'author__profile'),
@ -23,9 +24,23 @@ def show(request, course_id):
.order_by('order')
)
# Récupération de la progression de l'utilisateur sur le cours
user_progress = None
completed_lesson_ids = [] # On prépare une liste vide par défaut
if request.user.is_authenticated:
user_progress = Progression.objects.filter(user=request.user, course=course).first()
# 2. S'il y a une progression, on extrait juste les IDs des leçons finies
if user_progress:
# values_list renvoie une liste simple : [1, 5, 12] au lieu d'objets lourds
completed_lesson_ids = user_progress.completed_lessons.values_list('id', flat=True)
context = {
'course': course,
'lessons': lessons,
'user_progress': user_progress,
'completed_lesson_ids': completed_lesson_ids,
}
return render(request, 'courses/show.html', context)
@ -105,6 +120,18 @@ def lesson_detail(request, course_slug, module_slug, lesson_slug):
.order_by('created_at')
)
# Récupération de la progression de l'utilisateur sur le cours
user_progress = None
completed_lesson_ids = [] # On prépare une liste vide par défaut
if request.user.is_authenticated:
user_progress = Progression.objects.filter(user=request.user, course=course).first()
# 2. S'il y a une progression, on extrait juste les IDs des leçons finies
if user_progress:
# values_list renvoie une liste simple : [1, 5, 12] au lieu d'objets lourds
completed_lesson_ids = user_progress.completed_lessons.values_list('id', flat=True)
context = {
'course': course,
'module': module,
@ -114,5 +141,7 @@ def lesson_detail(request, course_slug, module_slug, lesson_slug):
'comment_form': form,
'prev_lesson': prev_lesson,
'next_lesson': next_lesson,
'user_progress': user_progress,
'completed_lesson_ids': completed_lesson_ids,
}
return render(request, 'courses/lesson.html', context)

View file

@ -12,6 +12,8 @@ https://docs.djangoproject.com/en/5.0/ref/settings/
from pathlib import Path
import os
import dotenv
from dotenv import load_dotenv
import devart.context_processor
@ -51,6 +53,8 @@ INSTALLED_APPS = [
'core',
'courses',
'users',
'progression',
'blog',
]
MIDDLEWARE = [
@ -62,7 +66,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'core.middleware.VisitTrackingMiddleware',
]
ROOT_URLCONF = 'devart.urls'
@ -81,6 +85,7 @@ TEMPLATES = [
'devart.context_processor.app_version',
'core.context_processor.site_settings',
'courses.context_processors.course_list',
'blog.context_processor.posts_list',
],
},
},
@ -200,3 +205,11 @@ def get_git_version():
return "Version inconnue (Fichier manquant)"
GIT_VERSION = get_git_version()
EMAIL_BACKEND = dotenv.get_key('.env', 'EMAIL_BACKEND')
EMAIL_HOST = dotenv.get_key('.env', 'EMAIL_HOST')
EMAIL_PORT = dotenv.get_key('.env', 'EMAIL_PORT')
EMAIL_USE_TLS = dotenv.get_key('.env', 'EMAIL_USE_TLS') == 'True'
EMAIL_HOST_USER = dotenv.get_key('.env', 'EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = dotenv.get_key('.env', 'EMAIL_HOST_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER

View file

@ -1,9 +1,10 @@
from django.contrib import sitemaps
from django.urls import reverse
# --- IMPORTS DEPUIS TES DIFFÉRENTES FEATURES ---
# --- FEATURES ---
from courses.models import Course
from users.models import Profile
from blog.models import Post
# --- SITEMAP : LES Cours ---
class CourseSitemap(sitemaps.Sitemap):
@ -11,12 +12,20 @@ class CourseSitemap(sitemaps.Sitemap):
priority = 0.9
def items(self):
return Course.objects.filter(enable=True) # Exemple de filtre
return Course.objects.filter(enable=True).order_by('id')
def location(self, item):
# Assure-toi que ton modèle Course a bien une méthode get_absolute_url
return item.get_absolute_url()
# --- SITEMAP : BLOG ---
class BlogSitemap(sitemaps.Sitemap):
changefreq = "weekly"
priority = 0.8
def location(self, item):
return item.get_absolute_url()
# --- SITEMAP : PAGES STATIQUES ---
class StaticViewSitemap(sitemaps.Sitemap):
priority = 0.5
@ -26,4 +35,4 @@ class StaticViewSitemap(sitemaps.Sitemap):
return ["home"] # Les noms de tes URLs
def location(self, item):
return "https://partirdezero.com"
return ""

View file

@ -43,6 +43,8 @@ urlpatterns = [
path('', include('home.urls')),
path('courses/', include('courses.urls')),
path('users/', include('users.urls')),
path('progression/', include('progression.urls')),
path('blog/', include('blog.urls')),
path('sitemap.xml', sitemap, {'sitemaps': sitemaps_dict}, name='django.contrib.sitemaps.views.sitemap'),
path('robots.txt', robots_txt),

View file

@ -5,4 +5,8 @@ app_name = 'home'
urlpatterns = [
path('', views.home, name='home'),
path('premium/<int:course_id>', views.premium, name='premium'),
# Tableau de bord statistiques (réservé superadministrateurs)
path('dashboard/stats/', views.stats_dashboard, name='stats_dashboard'),
path('dashboard/stats/charts/', views.stats_charts, name='stats_charts'),
path('dashboard/stats/live-activity/', views.live_activity, name='live_activity'),
]

View file

@ -1,5 +1,58 @@
from django.shortcuts import render, get_object_or_404
from courses.models import Course
from django.contrib.auth.decorators import user_passes_test
from django.utils import timezone
from django.db.models import Count
from core.models import Visit
from django.views.decorators.cache import cache_page
from django.contrib.auth.models import User
from courses.models import Course, Lesson
from blog.models import Post
from progression.models import Progression
import json
from django.http import JsonResponse
# --------------------
# Helpers Stats Module
# --------------------
def _parse_period(request, default=30, options=None):
"""Parse period parameter 'p' from request and compute date range.
Returns (p, now_dt, start_dt, start_date, end_date)
- now_dt is timezone-aware now
- start_dt is datetime at start of range (inclusive)
- start_date/end_date are date objects for convenient filtering
"""
if options is None:
options = [7, 30, 90, 180]
try:
p = int(request.GET.get('p', default))
except (TypeError, ValueError):
p = default
if p not in options:
p = default
now_dt = timezone.now()
start_dt = now_dt - timezone.timedelta(days=p - 1)
return p, now_dt, start_dt, start_dt.date(), now_dt.date()
def _build_series_for_range(start_date, end_date, qs, date_key='day', count_key='c'):
"""Build a continuous daily series from an aggregated queryset.
qs must yield dicts with date_key (date as string or date) and count_key.
Returns (labels_days, values_counts)
"""
counts = {str(item[date_key]): item[count_key] for item in qs}
days = []
values = []
d = start_date
while d <= end_date:
key = str(d)
days.append(key)
values.append(counts.get(key, 0))
d += timezone.timedelta(days=1)
return days, values
def home(request):
courses = Course.objects.order_by('-created_at')[:6]
@ -9,3 +62,295 @@ def premium(request, course_id):
"""Landing page présentant les avantages du Premium."""
course = get_object_or_404(Course, pk=course_id)
return render(request, 'premium.html', {'course': course})
# 15 minutes de cache sur la page complète (sauf si décochée plus tard pour des blocs en direct)
@user_passes_test(lambda u: u.is_superuser)
@cache_page(60 * 15)
def stats_dashboard(request):
""" Tableau de bord statistiques réservé aux superadministrateurs.
Périodes supportées: 7, 30, 90, 180 jours (GET param 'p'). """
# Période
period_options = [7, 30, 90, 180]
p, now, start_dt, period_start_date, period_end_date = _parse_period(
request, default=30, options=period_options
)
# Utilisateurs
total_users = User.objects.count()
new_users_qs = User.objects.filter(date_joined__date__gte=period_start_date, date_joined__date__lte=period_end_date)
# Séries quotidiennes nouveaux utilisateurs
new_users_by_day = (
new_users_qs
.extra(select={'day': "date(date_joined)"})
.values('day')
.annotate(c=Count('id'))
)
# Activité approximée via Progression mise à jour
active_users_qs = (
Progression.objects.filter(updated_at__date__gte=period_start_date, updated_at__date__lte=period_end_date)
.values('user').distinct()
)
active_users_count = active_users_qs.count()
# Cours
total_courses = Course.objects.count()
total_courses_enabled = Course.objects.filter(enable=True).count()
new_courses_by_day = (
Course.objects.filter(created_at__date__gte=period_start_date, created_at__date__lte=period_end_date)
.extra(select={'day': "date(created_at)"})
.values('day').annotate(c=Count('id'))
)
# Leçons
total_lessons = Lesson.objects.count()
# Achèvements de leçons (via table de liaison M2M)
through = Progression.completed_lessons.through
lesson_completions_by_day = (
through.objects.filter(
progression__updated_at__date__gte=start_dt.date(),
progression__updated_at__date__lte=now.date(),
)
.extra(select={'day': "date(progression_updated_at)"}) if 'progression_updated_at' in [f.name for f in through._meta.fields]
else through.objects.extra(select={'day': "date(created_at)"}) # fallback si champs créé n'existe pas
)
# Si la table M2M n'a pas de timestamps, on utilisera updated_at de Progression pour l'activité par jour
# donc on refait une série quotidienne d'activité progression
progress_activity_by_day = (
Progression.objects.filter(updated_at__date__gte=period_start_date, updated_at__date__lte=period_end_date)
.extra(select={'day': "date(updated_at)"})
.values('day').annotate(c=Count('id'))
)
# Blog
total_posts = Post.objects.count()
new_posts_by_day = (
Post.objects.filter(created_at__date__gte=period_start_date, created_at__date__lte=period_end_date)
.extra(select={'day': "date(created_at)"})
.values('day').annotate(c=Count('id'))
)
# Revenus/Paiements & Technique (placeholders faute de sources)
revenus_disponibles = False
technique_disponible = False
# Visites / Trafic
period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date)
unique_visitors = period_visits.values('visitor_id').distinct().count()
earlier_visitors_qs = Visit.objects.filter(date__lt=period_start_date).values('visitor_id').distinct()
returning_visitors = period_visits.filter(visitor_id__in=earlier_visitors_qs).values('visitor_id').distinct().count()
converted_visitors = (
period_visits
.filter(became_user_at__isnull=False, became_user_at__date__gte=period_start_date, became_user_at__date__lte=period_end_date)
.values('visitor_id').distinct().count()
)
top_sources_qs = (
period_visits
.values('source')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')
)
top_countries_qs = (
period_visits
.exclude(country='')
.values('country')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')
)
top_sources_table = [(row['source'] or 'Direct/Unknown', row['c']) for row in top_sources_qs[:10]]
top_countries_table = [(row['country'], row['c']) for row in top_countries_qs[:10]]
# Helper pour avoir toutes les dates de la période et remplir les trous
def build_series_dict(qs, date_key='day', count_key='c'):
# Wrapper conservant l'API locale mais utilisant le helper commun
return _build_series_for_range(period_start_date, period_end_date, qs, date_key=date_key, count_key=count_key)
days_users, values_new_users = build_series_dict(new_users_by_day)
days_courses, values_new_courses = build_series_dict(new_courses_by_day)
days_posts, values_new_posts = build_series_dict(new_posts_by_day)
days_activity, values_activity = build_series_dict(progress_activity_by_day)
# Tables simples (jour, valeur)
new_users_table = list(zip(days_users, values_new_users))
new_courses_table = list(zip(days_courses, values_new_courses))
context = {
'period_options': period_options,
'p': p,
'start_date': period_start_date,
'end_date': period_end_date,
# KPI
'kpi': {
'total_users': total_users,
'new_users_period': sum(values_new_users),
'active_users_period': active_users_count,
'unique_visitors': unique_visitors,
'returning_visitors': returning_visitors,
'converted_visitors': converted_visitors,
'total_courses': total_courses,
'courses_enabled': total_courses_enabled,
'total_lessons': total_lessons,
'total_posts': total_posts,
},
# Séries pour graphiques
'series': {
'days': days_users, # mêmes intervalles pour tous
'new_users': values_new_users,
'new_courses': values_new_courses,
'new_posts': values_new_posts,
'activity_progress': values_activity,
},
# Disponibilité des sections
'revenus_disponibles': revenus_disponibles,
'technique_disponible': technique_disponible,
# Tables
'new_users_table': new_users_table,
'new_courses_table': new_courses_table,
'top_sources_table': top_sources_table,
'top_countries_table': top_countries_table,
}
# Sérialisation JSON pour Chart.js
context['series_json'] = json.dumps(context['series'])
context['labels_json'] = json.dumps(context['series']['days'])
return render(request, 'home/stats_dashboard.html', context)
@user_passes_test(lambda u: u.is_superuser)
@cache_page(60 * 15)
def stats_charts(request):
"""Page dédiée aux graphiques (réservée superadmins)."""
# Période (utilise les mêmes helpers que le dashboard pour harmonisation)
period_options = [7, 30, 90, 180]
p, _now, _start_dt, period_start_date, period_end_date = _parse_period(
request, default=30, options=period_options
)
# Trafic par jour (visiteurs uniques)
visits_qs = (
Visit.objects
.filter(date__gte=period_start_date, date__lte=period_end_date)
.values('date')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('date')
)
# Conversions par jour (visiteurs devenus utilisateurs)
conversions_qs = (
Visit.objects
.filter(
became_user_at__isnull=False,
became_user_at__date__gte=period_start_date,
became_user_at__date__lte=period_end_date,
)
.extra(select={'day': "date(became_user_at)"})
.values('day')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('day')
)
def build_series_dict(qs, date_key='date', count_key='c'):
counts = {str(item[date_key]): item[count_key] for item in qs}
days = []
values = []
d = period_start_date
while d <= period_end_date:
key = str(d)
days.append(key)
values.append(counts.get(key, 0))
d += timezone.timedelta(days=1)
return days, values
labels, visitors_series = _build_series_for_range(period_start_date, period_end_date, visits_qs, date_key='date')
_, conversions_series = _build_series_for_range(period_start_date, period_end_date, conversions_qs, date_key='day')
# Sources & Pays (sur la période)
period_visits = Visit.objects.filter(date__gte=period_start_date, date__lte=period_end_date)
top_sources_qs = (
period_visits
.values('source')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')[:10]
)
top_countries_qs = (
period_visits
.exclude(country='')
.values('country')
.annotate(c=Count('visitor_id', distinct=True))
.order_by('-c')[:10]
)
sources_labels = [(row['source'] or 'Direct/Unknown') for row in top_sources_qs]
sources_values = [row['c'] for row in top_sources_qs]
countries_labels = [row['country'] for row in top_countries_qs]
countries_values = [row['c'] for row in top_countries_qs]
context = {
'period_options': period_options,
'p': p,
'start_date': period_start_date,
'end_date': period_end_date,
'labels_json': json.dumps(labels),
'visitors_series_json': json.dumps(visitors_series),
'conversions_series_json': json.dumps(conversions_series),
'sources_labels_json': json.dumps(sources_labels),
'sources_values_json': json.dumps(sources_values),
'countries_labels_json': json.dumps(countries_labels),
'countries_values_json': json.dumps(countries_values),
}
return render(request, 'home/stats_charts.html', context)
@user_passes_test(lambda u: u.is_superuser)
def live_activity(request):
"""Retourne en JSON l'activité récente (5 dernières minutes):
visiteurs et utilisateurs et leur page actuelle.
"""
now = timezone.now()
since = now - timezone.timedelta(minutes=5)
qs = (
Visit.objects
.filter(last_seen__gte=since)
.order_by('-last_seen')
)
data = []
for v in qs[:200]:
username = None
is_user = False
if v.user_id:
is_user = True
# safe access if user deleted
try:
username = v.user.username
except Exception:
username = 'Utilisateur'
visitor_label = v.visitor_id[:8]
seconds_ago = int((now - v.last_seen).total_seconds())
data.append({
'visitor': visitor_label,
'is_user': is_user,
'username': username,
'path': v.path,
'last_seen': v.last_seen.isoformat(),
'seconds_ago': seconds_ago,
'date': str(v.date),
'country': v.country,
'source': v.source,
})
return JsonResponse({'now': now.isoformat(), 'items': data})

0
progression/__init__.py Normal file
View file

15
progression/admin.py Normal file
View file

@ -0,0 +1,15 @@
from django.contrib import admin
from .models import Progression
from courses.models import Course, Lesson
@admin.register(Progression)
class ProgressionAdmin(admin.ModelAdmin):
list_display = ('user', 'course', 'get_percent', 'updated_at')
list_filter = ('course', 'updated_at')
search_fields = ('user__username', 'course__name')
autocomplete_fields = ['course', 'completed_lessons']
def get_percent(self, obj):
return f"{obj.percent_completed}"
get_percent.short_description = 'Progression'

5
progression/apps.py Normal file
View file

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ProgressionConfig(AppConfig):
name = 'progression'

View file

@ -0,0 +1,34 @@
# Generated by Django 6.0 on 2025-12-15 14:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('courses', '0003_comment_parent'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Progression',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('completed_lessons', models.ManyToManyField(blank=True, related_name='completed_by', to='courses.lesson')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_progress', to='courses.course')),
('last_viewed_lesson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='last_viewed_by', to='courses.lesson')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='progress', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Progression du cours',
'unique_together': {('user', 'course')},
},
),
]

View file

28
progression/models.py Normal file
View file

@ -0,0 +1,28 @@
from django.db import models
from django.conf import settings
class Progression(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='progress')
course = models.ForeignKey('courses.Course', on_delete=models.CASCADE, related_name='user_progress')
completed_lessons = models.ManyToManyField('courses.Lesson', blank=True, related_name='completed_by')
last_viewed_lesson = models.ForeignKey('courses.Lesson', on_delete=models.SET_NULL, null=True, blank=True, related_name='last_viewed_by')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('user', 'course')
verbose_name = "Progression du cours"
def __str__(self):
return f"{self.user} - {self.course.name}"
@property
def percent_completed(self):
from courses.models import Lesson
total_lessons = Lesson.objects.filter(module__course=self.course).count()
if total_lessons == 0:
return 0
completed_lessons = self.completed_lessons.count()
return int((completed_lessons / total_lessons) * 100)

3
progression/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
progression/urls.py Normal file
View file

@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = 'progression'
urlpatterns = [
# On ne garde QUE la route AJAX ici.
# Les routes 'list', 'detail', etc. doivent rester dans courses/urls.py
path('ajax/toggle-lesson/', views.toggle_lesson_completion, name='toggle_lesson'),
]

41
progression/views.py Normal file
View file

@ -0,0 +1,41 @@
from django.shortcuts import render, get_object_or_404
import json
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from progression.models import Progression
from courses.models import Lesson
@login_required
@require_POST
def toggle_lesson_completion(request):
data = json.loads(request.body)
lesson_id = data.get('lesson_id')
lesson = get_object_or_404(Lesson, id=lesson_id)
# On remonte au cours via le module (Lesson -> Module -> Course)
course = lesson.module.course
# On récupère ou crée la progression
progression, created = Progression.objects.get_or_create(
user=request.user,
course=course
)
# La logique du Toggle
if lesson in progression.completed_lessons.all():
progression.completed_lessons.remove(lesson)
is_completed = False
else:
progression.completed_lessons.add(lesson)
is_completed = True
# Mise à jour de la dernière leçon vue
progression.last_viewed_lesson = lesson
progression.save()
return JsonResponse({
'status': 'success',
'is_completed': is_completed,
'new_percent': progression.percent_completed
})

View file

@ -247,9 +247,43 @@ html { box-sizing: border-box; }
gap: 6px;
padding: 2px 8px;
margin-left: 8px;
border-radius: 999px;
border-radius: 2px;
background: var(--accent);
color: var(--warning-contrast);
color: var(--text);
font-size: 11px;
font-weight: 800;
letter-spacing: .3px;
line-height: 1.2;
vertical-align: middle;
text-transform: uppercase;
white-space: nowrap;
}
.current-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 2px;
background: var(--primary);
color: var(--primary-contrast);
font-size: 11px;
font-weight: 800;
letter-spacing: .3px;
line-height: 1.2;
vertical-align: middle;
text-transform: uppercase;
white-space: nowrap;
}
.completed-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 2px;
background: var(--success);
color: var(--success-contrast);
font-size: 11px;
font-weight: 800;
letter-spacing: .3px;
@ -269,6 +303,70 @@ html { box-sizing: border-box; }
font-weight: 500;
}
/* Progression des cours */
.progress-container {
display: flex;
flex-direction: row;
}
.progress-bar {
display: flex;
border-radius: var(--r-2);
border: 1px solid var(--border);
height: 20px;
background: var(--neutral-900);
}
.progress-text {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
color: var(--text);
font-size: 12px;
margin-left: 10px;
}
.progress-bar-fill {
background: var(--success);
height: 100%;
border-radius: var(--r-2) 0 0 var(--r-2);
}
.course-completed {
display: flex;
flex-direction: row;
border-radius: var(--r-2);
border: 1px solid var(--border);
margin: var(--space-2) 0;
background: var(--success);
}
.course-completed .container {
display: flex;
flex-direction: column;
padding: var(--space-2);
height: 100%;
vertical-align: middle;
}
.course-completed .container .icon {
color: var(--success-contrast);
font-size: 50px;
}
.course-completed .container .title {
color: var(--success-contrast);
font-size: 28px;
font-weight: 600;
}
.course-completed .container .content {
color: var(--success-contrast);
font-size: 14px;
}
[data-theme='light'] {
/* Palette: plus nuancé, moins "blanc" */
--bg: #eef3f7; /* fond légèrement teinté bleu-gris */
@ -751,6 +849,100 @@ img {
height: auto;
}
/* ======================================
Blog components
====================================== */
/* Accessibilité: élément visuellement masqué mais accessible aux lecteurs d'écran */
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0,0,0,0) !important;
white-space: nowrap !important;
border: 0 !important;
}
/* Blog layout wrappers */
.blog.blog-home .blog-header {
display: grid;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.blog-title {
margin: 0;
}
.blog-description {
color: var(--text-muted);
font-size: 1.05rem;
}
/* Post list grid */
.post-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-5);
}
.post-card {
background: var(--surface);
border: 1px solid var(--border-subtle);
border-radius: var(--r-3);
box-shadow: var(--shadow-1);
padding: var(--space-5);
display: grid;
gap: var(--space-3);
transition: transform var(--transition-1), box-shadow var(--transition-1), border-color var(--transition-1);
}
.post-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-2);
border-color: var(--border-strong);
}
.post-card-title {
margin: 0;
}
.post-card-title a { color: var(--fg); text-decoration: none; }
.post-card-title a:hover { color: var(--link-hover); text-decoration: underline; }
.post-excerpt { color: var(--text); opacity: 0.95; }
.post-actions { margin-top: 2px; }
/* Post meta (date, tags) */
.post-meta { color: var(--text-muted); font-size: 0.95rem; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.post-meta i { color: var(--muted); margin-right: 6px; }
.post-meta .sep { color: var(--muted); }
/* Post detail */
.post-detail .post-header { margin-bottom: var(--space-5); }
.post-detail .post-title { margin-bottom: var(--space-2); }
/* Prose content: typographic rhythm inside articles */
.prose {
line-height: 1.75;
color: var(--text);
}
.prose :where(p, ul, ol, blockquote, pre, table, img) { margin: 0 0 var(--space-4); }
.prose a { color: var(--link); }
.prose a:hover { color: var(--link-hover); text-decoration: underline; }
.prose blockquote {
margin-left: 0;
padding-left: var(--space-4);
border-left: 3px solid var(--border-strong);
color: var(--text-muted);
font-style: italic;
}
.prose code { font-family: var(--font-mono); background: var(--neutral-300); padding: 0 4px; border-radius: var(--r-1); }
.prose pre {
background: var(--code-bg);
color: var(--code-text);
padding: var(--space-4);
border-radius: var(--r-2);
overflow: auto;
}
/* Make embedded media responsive */
iframe, video { max-width: 100%; height: auto; }
@ -2138,6 +2330,16 @@ input[type="text"], input[type="email"], input[type="password"], textarea {
filter: brightness(1.05);
}
.btn-warning, .button-warning {
background-color: var(--warning);
border-color: var(--warning);
color: var(--warning-contrast);
}
.btn-warning:hover, .button-warning:hover {
filter: brightness(1.05);
}
/* Tailles */
.btn-sm, .button-sm { padding: 6px 10px; font-size: 0.9rem; }
.btn-lg, .button-lg { padding: 12px 20px; font-size: 1.05rem; }

99
static/css/christmas.css Normal file
View file

@ -0,0 +1,99 @@
/* PartirDeZero — Décos de Noël (chargées uniquement en décembre) */
/* Barre festive discrète sous la navbar */
.site-nav { position: relative; }
.site-nav::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: -1px;
height: 4px;
background: repeating-linear-gradient(45deg,
#d61c4e 0 12px,
#1b8f3a 12px 24px,
#ffffff 24px 36px);
opacity: .55;
pointer-events: none;
}
/* Emoji sapin à côté du titre */
.pdz-festive-emoji {
margin-left: .35rem;
filter: drop-shadow(0 1px 0 rgba(0,0,0,.2));
}
/* Overlay neige — ultra léger, non bloquant */
.pdz-snow {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 5; /* au-dessus du fond, sous les modales si existantes */
background-image:
radial-gradient(2px 2px at 20px 30px, rgba(255,255,255,.9) 50%, rgba(255,255,255,0) 51%),
radial-gradient(3px 3px at 80px 120px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%),
radial-gradient(2px 2px at 150px 80px, rgba(255,255,255,.85) 50%, rgba(255,255,255,0) 51%),
radial-gradient(3px 3px at 250px 20px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%);
background-repeat: repeat;
background-size: 300px 200px, 400px 300px, 350px 250px, 500px 400px;
animation: pdzSnowFall 18s linear infinite;
}
/* Plusieurs couches pour un effet de profondeur via parallax */
.pdz-snow::before,
.pdz-snow::after {
content: "";
position: absolute; inset: 0;
background-image:
radial-gradient(2px 2px at 40px 60px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%),
radial-gradient(3px 3px at 120px 200px, rgba(255,255,255,.75) 50%, rgba(255,255,255,0) 51%),
radial-gradient(2px 2px at 220px 160px, rgba(255,255,255,.8) 50%, rgba(255,255,255,0) 51%);
background-repeat: repeat;
}
.pdz-snow::before {
background-size: 260px 180px, 380px 280px, 320px 220px;
animation: pdzSnowFallSlow 28s linear infinite;
}
.pdz-snow::after {
background-size: 200px 140px, 300px 220px, 260px 200px;
animation: pdzSnowFallFast 12s linear infinite;
}
@keyframes pdzSnowFall {
from { transform: translateY(-10%); }
to { transform: translateY(100%); }
}
@keyframes pdzSnowFallSlow {
from { transform: translateY(-10%); }
to { transform: translateY(100%); }
}
@keyframes pdzSnowFallFast {
from { transform: translateY(-10%); }
to { transform: translateY(100%); }
}
/* Respect des préférences d'accessibilité */
@media (prefers-reduced-motion: reduce) {
.pdz-snow, .pdz-snow::before, .pdz-snow::after { animation: none; }
}
/* ——— Footer: petite touche festive discrète ——— */
footer.pdz-xmas { position: relative; }
footer.pdz-xmas::before {
content: "";
position: absolute;
left: 0; right: 0; top: 0;
height: 4px;
background: repeating-linear-gradient(45deg,
#d61c4e 0 12px,
#1b8f3a 12px 24px,
#ffffff 24px 36px);
opacity: .55;
pointer-events: none;
}
.footer-legal .pdz-holiday-greeting {
display: inline-flex;
align-items: center;
gap: .35rem;
color: var(--fg);
font-weight: 500;
}

View file

@ -95,27 +95,3 @@ document.addEventListener('DOMContentLoaded', function() {
}
} catch(e) {}
});
// Fonction pour générer des flocons de neige
function createSnowflake() {
const snowflake = document.createElement('div');
snowflake.classList.add('snowflake');
snowflake.textContent = '•';
snowflake.style.left = `${Math.random() * 100}vw`;
const size = Math.random() * 1.5 + 0.5;
snowflake.style.fontSize = `${size}em`;
const duration = Math.random() * 5 + 5;
snowflake.style.animationDuration = `${duration}s`;
document.body.appendChild(snowflake);
setTimeout(() => {
snowflake.remove();
}, duration * 1000);
}
// On génère les flocons toutes les 300ms
setInterval(createSnowflake, 300);

View file

@ -0,0 +1,25 @@
{% extends 'layout.html' %}
{% load comment_format %}
{% block title %} | Blog : {{ post.name }}{% endblock %}
{% block og_title %}Blog de Partir De Zéro : {{ post.name }}{% endblock %}
{% block description %}{{ post.content|striptags|truncatewords:20 }}{% endblock %}
{% block og_description %}{{ post.content|striptags|truncatewords:20 }}{% endblock %}
{% block content %}
<section class="blog post-detail">
<header class="post-header">
<h1 class="post-title">{{ post.name }}</h1>
<div class="post-meta">
<span class="post-date"><i class="fa-regular fa-calendar"></i> {{ post.created_at|date:"d F Y" }}</span>
{% if post.tags %}
<span class="sep"></span>
<span class="post-tags"><i class="fa-solid fa-tag"></i> {{ post.tags }}</span>
{% endif %}
</div>
</header>
<article class="post-content prose">
{{ post.content|comment_markdown }}
</article>
</section>
{% endblock %}

17
templates/blog/home.html Normal file
View file

@ -0,0 +1,17 @@
{% extends 'layout.html' %}
{% block title %} | Blog{% endblock %}
{% block og_title %}Blog de Partir De Zéro{% endblock %}
{% block description %}{{ settings.blog_description }}{% endblock %}
{% block og_description %}{{ settings.blog_description }}{% endblock %}
{% block content %}
<section class="blog blog-home">
<header class="blog-header">
<h1 class="blog-title">{{ settings.blog_title|default:'Blog' }}</h1>
<p class="blog-description">{{ settings.blog_description }}</p>
</header>
<h2 class="sr-only">Liste des articles</h2>
{% include 'blog/partials/_posts_list.html' %}
</section>
{% endblock %}

View file

@ -0,0 +1,21 @@
{% load comment_format %}
<div class="post-list">
{% for post in posts %}
<article class="post-card">
<h3 class="post-card-title"><a href="{% url 'blog:post_detail' post.slug %}">{{ post.name|truncatewords:6 }}</a></h3>
<div class="post-meta">
<span class="post-date"><i class="fa-regular fa-calendar"></i> {{ post.created_at|date:"d F Y" }}</span>
{% if post.tags %}
<span class="sep"></span>
<span class="post-tags"><i class="fa-solid fa-tag"></i> {{ post.tags }}</span>
{% endif %}
</div>
<p class="post-excerpt">{{ post.description|comment_markdown|truncatewords:26 }}</p>
<div class="post-actions">
<a class="button button-secondary" href="{% url 'blog:post_detail' post.slug %}">Lire l'article →</a>
</div>
</article>
{% empty %}
<p>Aucun article pour le moment.</p>
{% endfor %}
</div>

View file

@ -4,3 +4,22 @@
Un cours proposé par&nbsp;<a href="{% url 'another_profile' course.author.id %}">{{ course.author }}</a>
</p>
<p>{{ course.content }}</p>
<strong>Progression pour ce cours</strong>
{% if user_progress.percent_completed == 100 %}
<div class="course-completed">
<div class="container">
<div class="icon"><i class="fa-solid fa-check"></i></div>
</div>
<div class="container">
<div class="title">Félicitation</div>
<div class="content">Tu as terminé(e) ce cours ! Tu peux réellement être fier(e) de toi !!</div>
</div>
</div>
{% else %}
<div class="progress-container">
<div class="progress-bar" style="width: 100%;">
<div id="progress-bar-fill" class="progress-bar-fill" style="width: {{ user_progress.percent_completed }}%; height: 100%; transition: width 0.3s;"></div>
</div>
<div id="progress-text" class="progress-text">{{ user_progress.percent_completed }}%</div>
</div>
{% endif %}

View file

@ -13,7 +13,8 @@
<li class="tocLesson{% if lesson and lesson.id == item.id %} current{% endif %}">
<a class="tocLink {% if item.is_premium and user.profile.is_premium == False %}premium{% endif %}" href="{% url 'courses:lesson_detail' course.slug item.module.slug item.slug %}">
{{ item.name }} {% if item.is_premium %}<span class="premium-tag">PREMIUM</span>{% endif %}
{% if lesson and lesson.id == item.id %}<span class="tocCurrentTag">(cours actuel)</span>{% endif %}
{% if lesson and lesson.id == item.id %}<span class="current-tag">(cours actuel)</span>{% endif %}
{% if item.id in completed_lesson_ids %}<span class="completed-tag">Terminé</span>{% endif %}
</a>
{% if lesson and lesson.id == item.id %}
<div class="lessonInline">
@ -69,6 +70,12 @@
<div class="content-lesson">
<div class="lessonTitle">Ce que l'on voit durant ce cours : </div>
{{ lesson.content|comment_markdown }}
{% if lesson.id in completed_lesson_ids %}
<button id="btn-complete" data-lesson-id="{{ lesson.id }}" class="btn btn-success">✅ Terminée</button>
{% else %}
<button id="btn-complete" data-lesson-id="{{ lesson.id }}" class="btn btn-secondary">Marquer comme terminé</button>
{% endif %}
</div>
<h3 id="comments">Commentaires</h3>
<div class="lessonComments">
@ -251,6 +258,64 @@
});
})();
</script>
<!-- Progression Bar -->
<script>
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('btn-complete');
const progressBar = document.getElementById('progress-bar-fill');
const progressText = document.getElementById('progress-text');
btn.addEventListener('click', function() {
const lessonId = this.getAttribute('data-lesson-id');
// 1. On prépare la requête
fetch("{% url 'progression:toggle_lesson' %}", { // Assure-toi que c'est le bon nom dans urls.py
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken') // Fonction indispensable pour Django
},
body: JSON.stringify({
'lesson_id': lessonId
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
// 2. On met à jour le visuel de la barre
progressBar.style.width = data.new_percent + '%';
progressText.innerText = data.new_percent + '%';
// 3. On change l'aspect du bouton
if (data.is_completed) {
btn.innerText = "✅ Terminée";
btn.style.backgroundColor = "green";
} else {
btn.innerText = "Marquer comme terminé";
btn.style.backgroundColor = "gray";
}
}
});
});
// --- Fonction Helper pour récupérer le cookie CSRF de Django ---
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
});
</script>
</div> <!-- /.lessonComments -->
</div> <!-- /.lesson -->
</div> <!-- /.lessonInline -->

View file

@ -0,0 +1,78 @@
{% extends "layout.html" %}
{% load static %}
{% block title %}- Stats · Graphiques{% endblock %}
{% block extra_head %}
{% include "partials/_stats_head.html" %}
{% endblock %}
{% block content %}
<div class="container">
<h1>Graphiques statistiques</h1>
{% include "partials/_stats_toolbar.html" %}
<div class="grid">
<div class="card">
<h3>Visiteurs uniques par jour</h3>
<canvas id="chartVisitors" height="120"></canvas>
</div>
<div class="card">
<h3>Conversions (visiteurs devenus utilisateurs) par jour</h3>
<canvas id="chartConversions" height="120"></canvas>
</div>
</div>
<div class="grid-2">
<div class="card">
<h3>Top sources (visiteurs uniques)</h3>
<canvas id="chartSources" height="200"></canvas>
</div>
<div class="card">
<h3>Top pays (visiteurs uniques)</h3>
<canvas id="chartCountries" height="200"></canvas>
</div>
</div>
<p style="margin-top:16px"><a href="{% url 'home:stats_dashboard' %}">← Retour au tableau de bord</a></p>
</div>
<script>
const labels = {{ labels_json|safe }};
const visitorsSeries = {{ visitors_series_json|safe }};
const conversionsSeries = {{ conversions_series_json|safe }};
const sourcesLabels = {{ sources_labels_json|safe }};
const sourcesValues = {{ sources_values_json|safe }};
const countriesLabels = {{ countries_labels_json|safe }};
const countriesValues = {{ countries_values_json|safe }};
const ctxVisitors = document.getElementById('chartVisitors');
new Chart(ctxVisitors, {
type: 'line',
data: { labels, datasets: [{ label: 'Visiteurs uniques', data: visitorsSeries, borderColor: '#4f46e5', backgroundColor: 'rgba(79,70,229,.2)', tension:.2 }] },
options: { responsive: true, scales: { y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } }
});
const ctxConv = document.getElementById('chartConversions');
new Chart(ctxConv, {
type: 'line',
data: { labels, datasets: [{ label: 'Conversions', data: conversionsSeries, borderColor: '#059669', backgroundColor: 'rgba(5,150,105,.2)', tension:.2 }] },
options: { responsive: true, scales: { y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } }
});
const ctxSources = document.getElementById('chartSources');
new Chart(ctxSources, {
type: 'doughnut',
data: { labels: sourcesLabels, datasets: [{ data: sourcesValues, backgroundColor: ['#4f46e5','#059669','#f59e0b','#ef4444','#10b981','#3b82f6','#8b5cf6','#f97316','#22c55e','#14b8a6'] }] },
options: { plugins: { legend: { position: 'bottom' } } }
});
const ctxCountries = document.getElementById('chartCountries');
new Chart(ctxCountries, {
type: 'bar',
data: { labels: countriesLabels, datasets: [{ label: 'Visiteurs', data: countriesValues, backgroundColor: '#f59e0b' }] },
options: { responsive: true, scales: { y: { beginAtZero: true } }, plugins: { legend: { display: false } } }
});
</script>
{% endblock %}

View file

@ -0,0 +1,210 @@
{% extends "layout.html" %}
{% load static %}
{% block title %}- Tableau de bord{% endblock %}
{% block extra_head %}
{% include "partials/_stats_head.html" %}
{% endblock %}
{% block content %}
<div class="container">
<h1>Tableau de bord statistiques</h1>
<p style="margin:8px 0">
<a href="{% url 'home:stats_charts' %}">→ Voir la page de graphiques</a>
</p>
{% include "partials/_stats_toolbar.html" %}
<section class="stats-grid">
<div class="card">
<h3>Visiteurs uniques (période)</h3>
<div class="kpi">{{ kpi.unique_visitors }}</div>
</div>
<div class="card">
<h3>Visiteurs revenants (période)</h3>
<div class="kpi">{{ kpi.returning_visitors }}</div>
</div>
<div class="card">
<h3>Conversions en utilisateurs (période)</h3>
<div class="kpi">{{ kpi.converted_visitors }}</div>
</div>
<div class="card">
<h3>Utilisateurs (total)</h3>
<div class="kpi">{{ kpi.total_users }}</div>
</div>
<div class="card">
<h3>Nouveaux utilisateurs (période)</h3>
<div class="kpi">{{ kpi.new_users_period }}</div>
</div>
<div class="card">
<h3>Utilisateurs actifs (période)</h3>
<div class="kpi">{{ kpi.active_users_period }}</div>
</div>
<div class="card">
<h3>Cours (publiés / total)</h3>
<div class="kpi">{{ kpi.courses_enabled }} / {{ kpi.total_courses }}</div>
</div>
<div class="card">
<h3>Leçons (total)</h3>
<div class="kpi">{{ kpi.total_lessons }}</div>
</div>
<div class="card">
<h3>Articles de blog (total)</h3>
<div class="kpi">{{ kpi.total_posts }}</div>
</div>
<div class="card">
<h3>Revenus</h3>
{% if revenus_disponibles %}
<div class="kpi"></div>
{% else %}
<div class="kpi" title="Aucune source de données">N/A</div>
{% endif %}
</div>
<div class="card">
<h3>Technique</h3>
{% if technique_disponible %}
<div class="kpi"></div>
{% else %}
<div class="kpi" title="Aucune source de données">N/A</div>
{% endif %}
</div>
</section>
<section class="tables" id="live-activity">
<div class="card" style="grid-column: 1 / -1;">
<h3>Activité en direct (5 dernières minutes)</h3>
<table id="live-activity-table" style="width:100%">
<thead>
<tr><th>Type</th><th>Identité</th><th>Page</th><th>Il y a</th><th>Source</th><th>Pays</th></tr>
</thead>
<tbody>
<tr id="live-empty"><td colspan="6">Chargement…</td></tr>
</tbody>
</table>
</div>
</section>
<section class="charts">
<div class="card">
<h3>Évolution quotidienne</h3>
<canvas id="chartMain" height="120"></canvas>
</div>
</section>
<section class="tables">
<div class="card">
<h3>Nouveaux utilisateurs par jour</h3>
<table>
<thead><tr><th>Jour</th><th>Nb</th></tr></thead>
<tbody>
{% for row in new_users_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3>Nouveaux cours par jour</h3>
<table>
<thead><tr><th>Jour</th><th>Nb</th></tr></thead>
<tbody>
{% for row in new_courses_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section class="tables">
<div class="card">
<h3>Top sources (visiteurs uniques)</h3>
<table>
<thead><tr><th>Source</th><th>Visiteurs</th></tr></thead>
<tbody>
{% for row in top_sources_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% empty %}
<tr><td colspan="2">Aucune donnée</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h3>Top pays (visiteurs uniques)</h3>
<table>
<thead><tr><th>Pays</th><th>Visiteurs</th></tr></thead>
<tbody>
{% for row in top_countries_table %}
<tr><td>{{ row.0 }}</td><td>{{ row.1 }}</td></tr>
{% empty %}
<tr><td colspan="2">Aucune donnée</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<script>
const labels = {{ labels_json|safe }};
const series = {{ series_json|safe }};
const ctx = document.getElementById('chartMain');
const chart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{label: 'Nouveaux utilisateurs', data: series.new_users, borderColor: '#4f46e5', backgroundColor: 'rgba(79,70,229,.2)', tension:.2},
{label: 'Nouveaux cours', data: series.new_courses, borderColor: '#059669', backgroundColor: 'rgba(5,150,105,.2)', tension:.2},
{label: 'Nouveaux articles', data: series.new_posts, borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,.2)', tension:.2},
{label: 'Activité progression', data: series.activity_progress, borderColor: '#ef4444', backgroundColor: 'rgba(239,68,68,.2)', tension:.2}
]
},
options: {
responsive: true,
scales: {y: {beginAtZero: true}},
plugins: {legend: {position: 'bottom'}}
}
});
// Live activity polling
function humanizeSeconds(s) {
if (s < 60) return s + 's';
const m = Math.floor(s/60); const r = s % 60;
if (m < 60) return m + 'm' + (r?(' '+r+'s'):'');
const h = Math.floor(m/60); const mr = m % 60; return h + 'h' + (mr?(' '+mr+'m'):'');
}
async function fetchLive() {
try {
const res = await fetch('{% url "home:live_activity" %}', {headers: {'Accept': 'application/json'}});
if (!res.ok) throw new Error('HTTP '+res.status);
const payload = await res.json();
const tbody = document.querySelector('#live-activity-table tbody');
tbody.innerHTML = '';
const items = payload.items || [];
if (items.length === 0) {
const tr = document.createElement('tr');
const td = document.createElement('td'); td.colSpan = 6; td.textContent = 'Aucune activité récente';
tr.appendChild(td); tbody.appendChild(tr); return;
}
for (const it of items) {
const tr = document.createElement('tr');
const type = document.createElement('td'); type.textContent = it.is_user ? 'Utilisateur' : 'Visiteur';
const ident = document.createElement('td'); ident.textContent = it.is_user ? (it.username || 'Utilisateur') : it.visitor;
const page = document.createElement('td'); page.textContent = it.path || '/';
const ago = document.createElement('td'); ago.textContent = humanizeSeconds(it.seconds_ago);
const src = document.createElement('td'); src.textContent = it.source || '';
const country = document.createElement('td'); country.textContent = it.country || '';
tr.append(type, ident, page, ago, src, country);
tbody.appendChild(tr);
}
} catch (e) {
// silent fail in UI
}
}
fetchLive();
setInterval(fetchLive, 10000);
</script>
{% endblock %}

View file

@ -31,6 +31,37 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="shortcut icon" type="image/x-icon" href="{% static 'favicon.ico' %}">
{% now "n" as month %}
{% if month == '12' %}
<!-- Décorations de Noël (chargées uniquement en décembre) -->
<link rel="stylesheet" href="{% static 'css/christmas.css' %}">
<script>
// Fonction pour générer des flocons de neige
function createSnowflake() {
const snowflake = document.createElement('div');
snowflake.classList.add('snowflake');
snowflake.textContent = '•';
snowflake.style.left = `${Math.random() * 100}vw`;
const size = Math.random() * 1.5 + 0.5;
snowflake.style.fontSize = `${size}em`;
const duration = Math.random() * 5 + 5;
snowflake.style.animationDuration = `${duration}s`;
document.body.appendChild(snowflake);
setTimeout(() => {
snowflake.remove();
}, duration * 1000);
}
// On génère les flocons toutes les 300ms
setInterval(createSnowflake, 300);
</script>
{% endif %}
{% block extra_head %}{% endblock %}
<script src="{% static 'js/functions.js' %}" defer></script>
@ -39,8 +70,8 @@
(function() {
try {
var stored = localStorage.getItem('pdz-theme');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var theme = stored || (prefersLight ? 'light' : 'dark');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored || (prefersLight ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'light') {
var link = document.getElementById('theme-css');
@ -53,6 +84,11 @@
<script defer>hljs.highlightAll();</script>
</head>
<body>
{% now "n" as month %}
{% if month == '12' %}
<!-- Overlay neige discret, non interactif -->
<div class="pdz-snow" aria-hidden="true"></div>
{% endif %}
{% block header %}
{% include "partials/_header.html" %}
{% endblock %}

View file

@ -1,4 +1,5 @@
<footer class="site-footer">
{% now "n" as month %}
<footer class="site-footer{% if month == '12' %} pdz-xmas{% endif %}">
<div class="footer-columns">
<div class="about">
<h5>À propos</h5>
@ -35,5 +36,8 @@
<span>Partir de Zero ©2024 - {% now "Y" %}</span>
<span>v{{ SITE_VERSION }}</span>
<span>Site fièrement créer par <a href="https://av-interactive.be" target="_blank" rel="noopener">AV Interactive</a></span>
{% if month == '12' %}
<span class="pdz-holiday-greeting" aria-label="Joyeuses fêtes">Joyeuses fêtes <span class="pdz-festive-emoji" role="img" aria-hidden="true">🎄</span></span>
{% endif %}
</div>
</footer>

View file

@ -2,7 +2,12 @@
<div class="brand">
<div class="brand-title">
{% if settings.site_logo %}<img src="{{ settings.site_logo.url }}" alt="{{ settings.site_name|default:'Logo' }}" class="logo" style="max-height:64px; height:auto; vertical-align:middle;">{% endif %}
<span class="site-title">Partir de zéro</span>
<span class="site-title">Partir de zéro
{% now "n" as month %}
{% if month == '12' %}
<span class="pdz-festive-emoji" aria-hidden="true" title="Joyeuses fêtes">🎄</span>
{% endif %}
</span>
</div>
<span class="subtitle comment">/* Anthony Violet */</span>
</div>
@ -20,8 +25,8 @@
{% endfor %}
</ul>
</li>
<!--<li><a href="">Tutoriels</a></li>
<li><a href="">Billets</a></li>-->
<!--<li><a href="">Tutoriels</a></li>-->
<li><a href="{% url 'blog:blog' %}">Blog</a></li>
</ul>
<div class="navend">
<ul>
@ -43,6 +48,7 @@
{% if user.is_authenticated and user.is_staff %}
<li>
<a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a>
<a href="{% url 'home:stats_dashboard' %}" class="button button-warning" title="Stats">Stats</a>
</li>
{% endif %}
<li>

View file

@ -0,0 +1,65 @@
{# Partials: Shared head for stats pages (Chart.js + unified styles) #}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
/* Utilities */
.text-muted{color:var(--text-muted)}
/* Cards and basic blocks */
.card{
background:var(--surface);
border-radius:var(--r-3);
padding:var(--space-4);
border:1px solid var(--border-subtle);
box-shadow:var(--shadow-1);
transition:transform var(--transition-1), box-shadow var(--transition-1);
}
.card:hover{transform:translateY(-2px);box-shadow:var(--shadow-2)}
.card h3{margin:0 0 8px 0;font-size:16px;color:var(--text-muted);letter-spacing:.2px}
.kpi{font-size:clamp(22px,4vw,32px);font-weight:700;line-height:1.2}
.toolbar{display:flex;gap:12px;align-items:center;justify-content:space-between;margin:8px 0;padding:4px 0}
.toolbar label{color:var(--text-muted)}
.toolbar select{background:var(--surface);color:var(--text);border:1px solid var(--border-subtle);border-radius:var(--r-2);padding:6px 10px}
/* Grids used across stats pages */
.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:var(--space-4);margin:16px 0}
.charts{display:grid;grid-template-columns:1fr;gap:24px;margin:24px 0}
.tables{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.grid{display:grid;grid-template-columns:1fr;gap:24px;margin:16px 0}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:24px}
@media (max-width: 1280px){
.stats-grid{grid-template-columns:repeat(3,1fr)}
}
/* Responsive */
@media (max-width: 960px){
.stats-grid{grid-template-columns:repeat(2,1fr)}
.tables{grid-template-columns:1fr}
.grid-2{grid-template-columns:1fr}
}
@media (max-width: 560px){
.toolbar{flex-direction:column;align-items:flex-start;gap:8px}
}
/* Tables */
table{width:100%;border-collapse:collapse}
thead th{font-weight:600;text-align:left;border-bottom:1px solid var(--border-subtle);padding:8px 6px;color:var(--text-muted)}
tbody td{border-bottom:1px solid var(--border-subtle);padding:8px 6px}
tbody tr:nth-child(even){background:rgba(255,255,255,0.02)}
tbody tr:hover{background:rgba(255,255,255,0.04)}
</style>
<script>
// Harmonize Chart.js defaults with theme tokens
if (window.Chart) {
try {
const cs = getComputedStyle(document.documentElement);
const text = cs.getPropertyValue('--text') || '#c4d7e0';
const font = cs.getPropertyValue('--font-sans') || 'system-ui, sans-serif';
Chart.defaults.color = String(text).trim();
Chart.defaults.font.family = String(font).trim();
Chart.defaults.plugins.legend.labels.boxWidth = 12;
} catch (e) {
// no-op
}
}
</script>

View file

@ -0,0 +1,15 @@
{# Partials: Shared period toolbar for stats pages #}
<form method="get" class="toolbar">
<div>
<label for="p">Période:&nbsp;</label>
<select id="p" name="p" onchange="this.form.submit()">
{% for opt in period_options %}
<option value="{{ opt }}" {% if p == opt %}selected{% endif %}>{{ opt }} jours</option>
{% endfor %}
</select>
<span style="margin-left:8px" class="text-muted">Du {{ start_date }} au {{ end_date }}</span>
</div>
<div class="text-muted">Mise en cache 15 minutes</div>
{% block stats_toolbar_extra %}{% endblock %}
{# Keep block for optional extensions on specific pages #}
</form>

View file

@ -6,12 +6,27 @@
{% endblock %}
<div class="profile-details">
<h2>Mes cours</h2>
<p>Retrouvez ici la liste de tous les cours que vous suivez.</p>
<ul>
{% for course in user_courses %}
<li><a href="{% url 'courses:show' course.name|slugify course.id %}">{{ course.name }}</a></li>
<p>Retrouvez ici la liste de tous les cours que vous suivez et votre progression.</p>
{% if progress_list %}
<ul class="profile-courses">
{% for p in progress_list %}
{% with course=p.course %}
<li>
<a href="{% url 'courses:show' course.name|slugify course.id %}">
{% if course.thumbnail %}
<img src="/{{ course.thumbnail }}" alt="{{ course.name }}" class="course-thumb-mini">
{% endif %}
<span>{{ course.name }}</span>
</a>
<div class="muted small">Progression: {{ p.percent_completed }}%</div>
</li>
{% endwith %}
{% endfor %}
</ul>
{% else %}
<p class="muted">Vous ne suivez aucun cours pour le moment.</p>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -22,16 +22,19 @@
<div class="profile-card">
<h3>Mes cours</h3>
{% with courses=user.course_set.all %}
{% if courses %}
{% with progress_list=latest_progress %}
{% if progress_list %}
<ul class="profile-courses">
{% for course in courses|slice:":6" %}
{% for p in progress_list %}
{% with course=p.course %}
<li>
<a href="{% url 'courses:show' course_id=course.id course_name=course.slug %}">
<img src="/{{ course.thumbnail }}" alt="{{ course.name }}" class="course-thumb-mini">
<span>{{ course.name }}</span>
</a>
<div class="muted small">Progression: {{ p.percent_completed }}%</div>
</li>
{% endwith %}
{% endfor %}
</ul>
<div class="text-right">

View file

@ -27,7 +27,15 @@ class UserRegistrationForm(forms.Form):
password2 = cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
raise forms.ValidationError("Passwords do not match")
raise forms.ValidationError("Les mots de passe ne correspondent pas.")
return cleaned_data
def clean_username(self):
username = self.cleaned_data.get('username')
if username and User.objects.filter(username__iexact=username).exists():
raise forms.ValidationError("Ce pseudo est déjà pris. Veuillez en choisir un autre.")
return username
class UserLoginForm(forms.Form):
username = forms.CharField(

4
users/tokens.py Normal file
View file

@ -0,0 +1,4 @@
from django.contrib.auth.tokens import PasswordResetTokenGenerator
# Générateur de tokens pour l'activation de compte
activation_token = PasswordResetTokenGenerator()

View file

@ -7,6 +7,8 @@ urlpatterns = [
path('', views.register, name='register'),
path('login/', views.login, name='login'),
path('logout/', views.logout, name='logout'),
# Activation de compte par lien tokenisé
path('activate/<uidb64>/<token>/', views.activate, name='activate'),
path('profile/view/<int:user_id>/', views.another_profile, name='another_profile'),
path('complete-profile/', views.complete_profile, name='complete_profile'),
path('profile/', views.profile, name='profile'),

View file

@ -3,8 +3,14 @@ from django.contrib import messages
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
from django.contrib.auth.models import User
from courses.models import Course
from progression.models import Progression
from .forms import UserRegistrationForm, UserLoginForm, UserUpdateForm, ProfileUpdateForm, CompleteProfileForm
from django.contrib.auth.decorators import login_required
from django.core.mail import send_mail
from django.urls import reverse
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
from django.utils.encoding import force_bytes
from .tokens import activation_token
def register(request):
# Si l'utilisateur est deja connecté, on le redirige vers la page de profil
@ -13,13 +19,36 @@ def register(request):
if request.method == 'POST':
form = UserRegistrationForm(request.POST)
if form.is_valid():
# Crée un utilisateur inactif en attente d'activation par email
user = User.objects.create_user(
username=form.cleaned_data['username'],
email=form.cleaned_data['email'],
password=form.cleaned_data['password1']
)
auth_login(request, user)
return redirect('profile')
user.is_active = False
user.save()
# Envoi du lien d'activation par email
uid = urlsafe_base64_encode(force_bytes(user.pk))
token = activation_token.make_token(user)
activation_link = request.build_absolute_uri(
reverse('activate', kwargs={'uidb64': uid, 'token': token})
)
subject = 'Active ton compte sur partirdezero'
message = (
'Bienvenue !\n\n'
'Pour activer ton compte, clique sur le lien suivant (valide pendant une durée limitée) :\n'
f'{activation_link}\n\n'
"Si tu n'es pas à l'origine de cette inscription, ignore ce message."
)
try:
send_mail(subject, message, None, [user.email], fail_silently=True)
except Exception:
# Même si l'envoi échoue, on n'interrompt pas le flux; un admin vérifiera la config email
pass
messages.success(request, "Inscription réussie. Vérifie ta boîte mail pour activer ton compte.")
return redirect('login')
else:
form = UserRegistrationForm()
return render(request, 'users/register.html', {'form': form})
@ -32,8 +61,15 @@ def login(request):
password = form.cleaned_data['password']
user = authenticate(request, username=username, password=password)
if user is not None:
if not user.is_active:
messages.error(request, "Votre compte n'est pas encore activé. Consultez l'email d'activation envoyé." )
return render(request, 'users/login.html', {'form': form})
auth_login(request, user)
return redirect('profile')
else:
# Identifiants invalides: avertir l'utilisateur
messages.error(request, "Nom d'utilisateur ou mot de passe incorrect.")
return render(request, 'users/login.html', {'form': form})
else:
form = UserLoginForm()
return render(request, 'users/login.html', {'form': form})
@ -46,7 +82,17 @@ def logout(request):
def profile(request):
if not hasattr(request.user, 'profile'):
return redirect('complete_profile')
return render(request, 'users/profile.html')
latest_progress = (
Progression.objects
.filter(user=request.user)
.select_related('course')
.prefetch_related('completed_lessons')
.order_by('-updated_at')[:5]
)
# Affiche les 5 derniers cours regardés avec leur progression
return render(request, 'users/profile.html', {'latest_progress': latest_progress})
@login_required(login_url='login')
def complete_profile(request):
@ -95,9 +141,16 @@ def account_update(request):
@login_required(login_url='login')
def my_courses(request):
user_courses = Course.objects.filter(author=request.user.id)
print(user_courses)
return render(request, 'users/my_courses.html', {'user_courses' : user_courses})
# Liste tous les cours suivis par l'utilisateur avec leur progression
progress_list = (
Progression.objects
.filter(user=request.user)
.select_related('course')
.prefetch_related('completed_lessons')
.order_by('-updated_at')
)
return render(request, 'users/my_courses.html', {'progress_list': progress_list})
def create_post(request):
# Implement post creation logic here
@ -106,3 +159,21 @@ def create_post(request):
def another_profile(request, user_id):
user = User.objects.get(id=user_id)
return render(request, 'users/another_profile.html', {'user': user})
# Activation de compte via lien tokenisé
def activate(request, uidb64, token):
try:
uid = urlsafe_base64_decode(uidb64).decode()
user = User.objects.get(pk=uid)
except Exception:
user = None
if user and activation_token.check_token(user, token):
if not user.is_active:
user.is_active = True
user.save()
messages.success(request, 'Votre compte a été activé. Vous pouvez maintenant vous connecter.')
return redirect('login')
messages.error(request, "Lien d'activation invalide ou expiré. Demandez un nouveau lien ou inscrivezvous à nouveau.")
return redirect('register')