Compare commits
35 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 38e4ce2562 | |||
| 18b807bf5a | |||
| d20302be0e | |||
| 7cf04968eb | |||
| a7b51e3a82 | |||
| 8f0fad45be | |||
| 81b42b8b4a | |||
| afa673ccd9 | |||
| 5fd06f5ae1 | |||
| 5a241e394b | |||
| 91f7f79546 | |||
| e1f8a23f3d | |||
| 1b0ccc54a2 | |||
| f9e2df559c | |||
| 7869abf441 | |||
| 083af6f9d4 | |||
| 6e8a2bc287 | |||
| bec74976ba | |||
| b3b18bd15a | |||
| fc4939577a | |||
| ca0211e841 | |||
| ab8307b272 | |||
| 2c715a3af4 | |||
| e44e31bfb9 | |||
| 82c2e234e3 | |||
| ac8ef6894d | |||
| 609745a723 | |||
| 4a48425374 | |||
| 43af8bd0d8 | |||
| c1749068af | |||
| 3e44013132 | |||
| 45d2cb66f0 | |||
| 2bfab05b49 | |||
| 6c7f91c72f | |||
| 84c94e28de |
58 changed files with 1977 additions and 76 deletions
|
|
@ -1 +1 @@
|
|||
1.0.3 (e79ffee)
|
||||
1.3.8 (7cf0496)
|
||||
0
blog/__init__.py
Normal file
0
blog/__init__.py
Normal file
8
blog/admin.py
Normal file
8
blog/admin.py
Normal 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
5
blog/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BlogConfig(AppConfig):
|
||||
name = 'blog'
|
||||
5
blog/context_processor.py
Normal file
5
blog/context_processor.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from .models import Post
|
||||
|
||||
def posts_list(request):
|
||||
posts = Post.objects.all().order_by('-created_at')
|
||||
return {'posts': posts}
|
||||
30
blog/migrations/0001_initial.py
Normal file
30
blog/migrations/0001_initial.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
18
blog/migrations/0002_post_enable.py
Normal file
18
blog/migrations/0002_post_enable.py
Normal 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),
|
||||
),
|
||||
]
|
||||
18
blog/migrations/0003_post_description.py
Normal file
18
blog/migrations/0003_post_description.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
0
blog/migrations/__init__.py
Normal file
0
blog/migrations/__init__.py
Normal file
18
blog/models.py
Normal file
18
blog/models.py
Normal 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
3
blog/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
8
blog/urls.py
Normal file
8
blog/urls.py
Normal 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
11
blog/views.py
Normal 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})
|
||||
|
|
@ -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
121
core/middleware.py
Normal 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'])
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
41
core/migrations/0004_visit.py
Normal file
41
core/migrations/0004_visit.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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'},
|
||||
),
|
||||
]
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"\*\*([^*]+)\*\*",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
]
|
||||
347
home/views.py
347
home/views.py
|
|
@ -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
0
progression/__init__.py
Normal file
15
progression/admin.py
Normal file
15
progression/admin.py
Normal 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
5
progression/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProgressionConfig(AppConfig):
|
||||
name = 'progression'
|
||||
34
progression/migrations/0001_initial.py
Normal file
34
progression/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
progression/migrations/__init__.py
Normal file
0
progression/migrations/__init__.py
Normal file
28
progression/models.py
Normal file
28
progression/models.py
Normal 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
3
progression/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
10
progression/urls.py
Normal file
10
progression/urls.py
Normal 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
41
progression/views.py
Normal 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
|
||||
})
|
||||
|
|
@ -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
99
static/css/christmas.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
25
templates/blog/details.html
Normal file
25
templates/blog/details.html
Normal 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
17
templates/blog/home.html
Normal 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 %}
|
||||
21
templates/blog/partials/_posts_list.html
Normal file
21
templates/blog/partials/_posts_list.html
Normal 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>
|
||||
|
|
@ -4,3 +4,22 @@
|
|||
Un cours proposé par <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 %}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
78
templates/home/stats_charts.html
Normal file
78
templates/home/stats_charts.html
Normal 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 %}
|
||||
210
templates/home/stats_dashboard.html
Normal file
210
templates/home/stats_dashboard.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
65
templates/partials/_stats_head.html
Normal file
65
templates/partials/_stats_head.html
Normal 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>
|
||||
15
templates/partials/_stats_toolbar.html
Normal file
15
templates/partials/_stats_toolbar.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{# Partials: Shared period toolbar for stats pages #}
|
||||
<form method="get" class="toolbar">
|
||||
<div>
|
||||
<label for="p">Période: </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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
4
users/tokens.py
Normal 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()
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 e‑mail
|
||||
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 e‑mail
|
||||
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 e‑mail
|
||||
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'e‑mail 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 inscrivez‑vous à nouveau.")
|
||||
return redirect('register')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue