Compare commits

..

No commits in common. "master" and "0.1.1" have entirely different histories.

71 changed files with 160 additions and 2464 deletions

View file

@ -1 +0,0 @@
1.4.1 (e9754c2)

View file

View file

@ -1,8 +0,0 @@
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",)}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,18 +0,0 @@
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

View file

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

View file

@ -1,8 +0,0 @@
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'),
]

View file

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

View file

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import SiteSettings, Visit, Maintenance
from .models import SiteSettings
@admin.register(SiteSettings)
class SiteSettingsAdmin(admin.ModelAdmin):
@ -14,7 +14,7 @@ class SiteSettingsAdmin(admin.ModelAdmin):
# Petite astuce visuelle pour l'admin
fieldsets = (
('Général', {
'fields': ('site_name', 'site_logo', 'receive_emails_active')
'fields': ('site_name', 'site_logo')
}),
('Réseaux Sociaux', {
'fields': ('facebook_url', 'twitter_url', 'youtube_url'),
@ -23,18 +23,4 @@ class SiteSettingsAdmin(admin.ModelAdmin):
('Contact', {
'fields': ('contact_email',)
}),
('Blog', {
'fields': ('blog_title', 'blog_description')
}),
)
@admin.register(Maintenance)
class MaintenanceAdmin(admin.ModelAdmin):
list_display = ("name","start_date", "end_date")
@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")

View file

@ -1,7 +1,5 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'
def ready(self):
import courses.signals

View file

@ -1,14 +1,5 @@
from django.utils.timesince import timesince
from .models import SiteSettings, Maintenance
from .models import SiteSettings
def site_settings(request):
# On récupère le premier objet, ou None s'il n'existe pas encore
return {'settings': SiteSettings.objects.first()}
def site_maintenance(request):
last = Maintenance.objects.last()
start = last.start_date if last else None
end = last.end_date if last else None
delay = timesince(start, end) if start and end else None
return {'maintenance': Maintenance.objects.last(), 'delay': delay}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2025-12-17 09:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_visit'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='receive_emails_active',
field=models.BooleanField(default=True),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 6.0 on 2025-12-17 10:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_sitesettings_receive_emails_active'),
]
operations = [
migrations.CreateModel(
name='Maintenance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_active', models.BooleanField(default=False)),
('message', models.TextField(blank=True)),
('start_date', models.DateTimeField(blank=True, null=True)),
('end_date', models.DateTimeField(blank=True, null=True)),
],
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2025-12-17 10:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_maintenance'),
]
operations = [
migrations.AddField(
model_name='maintenance',
name='name',
field=models.CharField(blank=True, max_length=200),
),
]

View file

@ -1,12 +1,9 @@
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")
site_logo = models.ImageField(upload_to='settings/', blank=True)
contact_email = models.EmailField(blank=True)
receive_emails_active = models.BooleanField(default=True)
# Réseaux sociaux
facebook_url = models.URLField(blank=True)
@ -16,10 +13,6 @@ 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.
@ -34,41 +27,3 @@ class SiteSettings(models.Model):
class Meta:
verbose_name = "Réglages du site"
verbose_name_plural = "Réglages du site"
class Maintenance(models.Model):
is_active = models.BooleanField(default=False)
name = models.CharField(max_length=200, blank=True)
message = models.TextField(blank=True)
start_date = models.DateTimeField(blank=True, null=True)
end_date = models.DateTimeField(blank=True, null=True)
class Visit(models.Model):
"""Enregistrement simplifié des visites (agrégées par jour et visiteur).
Objectif: fournir des stats de base sans dépendances externes.
"""
visitor_id = models.CharField(max_length=64, db_index=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name='visits'
)
date = models.DateField(db_index=True)
first_seen = models.DateTimeField(default=timezone.now)
last_seen = models.DateTimeField(default=timezone.now)
path = models.CharField(max_length=512, blank=True)
referrer = models.CharField(max_length=512, blank=True)
utm_source = models.CharField(max_length=100, blank=True)
utm_medium = models.CharField(max_length=100, blank=True)
utm_campaign = models.CharField(max_length=150, blank=True)
source = models.CharField(max_length=150, blank=True, help_text="Domaine de provenance ou utm_source")
country = models.CharField(max_length=64, blank=True)
# Conversion: première fois où un visiteur devient utilisateur authentifié
became_user_at = models.DateTimeField(null=True, blank=True)
class Meta:
unique_together = ('visitor_id', 'date')
ordering = ['-date', '-last_seen']
def __str__(self):
return f"{self.visitor_id} @ {self.date}"

View file

@ -1,9 +0,0 @@
from django.urls import path
from . import views
urlpatterns = [
path('update_database', views.update_database, name='update_database'),
path('clear_cache', views.clear_cache, name='clear_cache'),
path('regen_static_files', views.regen_static_files, name='regen_static_files'),
path('reload_server', views.reload_server, name='reload_server')
]

View file

@ -1,23 +1,3 @@
from django.shortcuts import render
from django.core.management import call_command
from django.core.cache import cache
import subprocess
def update_database(request):
call_command('makemigrations')
call_command('migrate')
message = "La base de données à bien été mise à jour !"
return render(request, 'home.html', {'message': message})
def clear_cache(request):
cache.clear()
message = "Le cache à bien été effacé !"
return render(request, 'home.html', {'message': message})
def regen_static_files(request):
call_command('collectstatic', '--noinput')
message = "Les fichiers statics ont bien été générés !"
return render(request, 'home.html', {'message': message})
def reload_server(request):
pass
# Create your views here.

View file

@ -1,8 +1,5 @@
from .models import Course, Comment
from .models import Course
def course_list(request):
courses = Course.objects.all()
return {'courses': courses}
def courses_comments(request):
return {'comments_count': Comment.objects.all()}

View file

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

View file

@ -12,16 +12,9 @@ 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
def get_absolute_url(self):
return f"/courses/{self.slug}-{self.id}/"
class Module(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField()
@ -32,9 +25,6 @@ class Module(models.Model):
enable = models.BooleanField(default=True)
order = models.PositiveIntegerField()
def __str__(self):
return self.name
class Lesson(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField()
@ -44,10 +34,6 @@ 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:
@ -57,9 +43,6 @@ class Lesson(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return f"/courses/{self.module.course.slug}/{self.module.slug}/{self.slug}/"
class Comment(models.Model):
lesson = models.ForeignKey(Lesson, on_delete=models.CASCADE, related_name='comments')

View file

@ -1,12 +0,0 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.core.mail import send_mail
from .models import Comment
from core.models import SiteSettings
@receiver(post_save, sender=Comment)
def send_email_notification(sender, instance, created, **kwargs):
if created and SiteSettings.objects.first().receive_emails_active:
subject = f"Nouveau commentaire sur la leçon - {instance.lesson.name} du cours {instance.lesson.module.course.name}"
message = f"Le commentaire suivant à été envoyé par {instance.user}:\n{instance.content}"
send_mail(subject, message, "infos@partirdezero.com", ['anthony.violet@outlook.be'], fail_silently=False)

View file

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

View file

@ -3,7 +3,6 @@ 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):
@ -24,23 +23,9 @@ def show(request, course_name, 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)
@ -120,18 +105,6 @@ 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,
@ -141,7 +114,5 @@ def lesson_detail(request, course_slug, module_slug, lesson_slug):
'comment_form': form,
'prev_lesson': prev_lesson,
'next_lesson': next_lesson,
'user_progress': user_progress,
'completed_lesson_ids': completed_lesson_ids,
}
return render(request, 'courses/lesson.html', context)

View file

@ -1,4 +0,0 @@
from django.conf import settings
def app_version(request):
return {'SITE_VERSION': settings.GIT_VERSION}

View file

@ -12,12 +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
# Charger les variables d'environnement depuis le fichier .env
load_dotenv()
@ -48,13 +44,10 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sitemaps',
'core',
'courses',
'users',
'progression',
'blog',
]
MIDDLEWARE = [
@ -66,7 +59,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'core.middleware.VisitTrackingMiddleware',
]
ROOT_URLCONF = 'devart.urls'
@ -82,11 +75,8 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'devart.context_processor.app_version',
'core.context_processor.site_settings',
'core.context_processor.site_maintenance',
'courses.context_processors.course_list',
'blog.context_processor.posts_list',
],
},
},
@ -162,55 +152,3 @@ STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')]
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# On défini une fonction qui va s'occuper de récupérer la version actuelle de l'application
def get_git_version():
import subprocess
"""
DEV : Calcule la version Git et met à jour le fichier VERSION.txt
PROD : Lit simplement le fichier VERSION.txt
"""
version_file = os.path.join(BASE_DIR, 'VERSION.txt')
# --- CAS 1 : MODE DÉVELOPPEMENT (Mise à jour du fichier) ---
if DEBUG:
try:
# 1. On récupère les infos Git
hash_part = subprocess.check_output(['git', 'rev-parse', '--short', 'HEAD'],
stderr=subprocess.DEVNULL).strip().decode('utf-8')
try:
tag_part = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"],
stderr=subprocess.DEVNULL).strip().decode('utf-8')
except:
tag_part = "v0.0.x" # Fallback si pas de tag
full_version = f"{tag_part} ({hash_part})"
# 2. On ÉCRIT/MET À JOUR le fichier texte
with open(version_file, 'w') as f:
f.write(full_version)
return full_version
except Exception as e:
# Si Git échoue en local (rare, mais possible)
return f"Dev Mode (Err: {e})"
# --- CAS 2 : MODE PRODUCTION (Lecture seule) ---
else:
if os.path.exists(version_file):
with open(version_file, 'r') as f:
return f.read().strip()
else:
return "Version inconnue (Fichier manquant)"
GIT_VERSION = get_git_version()
EMAIL_BACKEND = dotenv.get_key('.env', 'EMAIL_BACKEND')
EMAIL_HOST = dotenv.get_key('.env', 'EMAIL_HOST')
EMAIL_PORT = dotenv.get_key('.env', 'EMAIL_PORT')
EMAIL_USE_TLS = dotenv.get_key('.env', 'EMAIL_USE_TLS') == 'True'
EMAIL_HOST_USER = dotenv.get_key('.env', 'EMAIL_HOST_USER')
EMAIL_HOST_PASSWORD = dotenv.get_key('.env', 'EMAIL_HOST_PASSWORD')
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER

View file

@ -1,38 +0,0 @@
from django.contrib import sitemaps
from django.urls import reverse
# --- FEATURES ---
from courses.models import Course
from users.models import Profile
from blog.models import Post
# --- SITEMAP : LES Cours ---
class CourseSitemap(sitemaps.Sitemap):
changefreq = "weekly"
priority = 0.9
def items(self):
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
changefreq = "monthly"
def items(self):
return ["home"] # Les noms de tes URLs
def location(self, item):
return ""

View file

@ -18,38 +18,12 @@ from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include
from django.http import HttpResponse
from devart.sitemap import CourseSitemap, StaticViewSitemap
from django.contrib.sitemaps.views import sitemap
def robots_txt(request):
lines = [
"User-agent: *",
"Disallow: /admin/",
"Disallow: /users/",
"Disallow: /maintenance/",
"Disallow: /core/",
"Allow: /",
"Sitemap: https://partirdezero.com/sitemap.xml", # On indique déjà où sera le plan
]
return HttpResponse("\n".join(lines), content_type="text/plain")
sitemaps_dict = {
'cours': CourseSitemap,
'static': StaticViewSitemap,
}
urlpatterns = [
path('core/', include('core.urls')),
path('admin/', admin.site.urls),
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),
]
if settings.DEBUG:

View file

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

View file

@ -1,58 +1,5 @@
from django.shortcuts import render, get_object_or_404
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
from courses.models import Course
def home(request):
courses = Course.objects.order_by('-created_at')[:6]
@ -62,295 +9,3 @@ 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})

View file

@ -1,15 +0,0 @@
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'

View file

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

View file

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

View file

@ -1,28 +0,0 @@
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)

View file

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

View file

@ -1,10 +0,0 @@
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'),
]

View file

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

View file

@ -79,9 +79,8 @@
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* Layout */
/* Fluid container and gutters for responsive spacing */
--container-w: clamp(320px, 92vw, 1100px);
--gutter: clamp(14px, 3vw, 24px);
--container-w: 1100px;
--gutter: 20px;
--focus-ring: 0 0 0 3px rgba(78,158,214,0.45);
/* Utility base colors */
@ -109,23 +108,6 @@
--border-glass-light: rgba(255,255,255,0.05);
}
/*
Mobile width guardrail
- Cap container width on touch devices to improve readability and avoid edge overflows
*/
@media (hover: none) and (pointer: coarse) {
:root {
/* Keep fluid gutters but prevent overly wide lines on large/mobile landscape */
--container-w: min(92vw, 480px);
}
html, body { overflow-x: hidden; }
}
/* Global box-sizing reset to prevent width overflow on mobile
Ensures padding/border are included in the element's total width/height */
html { box-sizing: border-box; }
*, *::before, *::after { box-sizing: inherit; }
/* Light theme values — applied via colors_light.css inclusion */
/* ==========================
@ -247,9 +229,9 @@ html { box-sizing: border-box; }
gap: 6px;
padding: 2px 8px;
margin-left: 8px;
border-radius: 2px;
border-radius: 999px;
background: var(--accent);
color: var(--text);
color: var(--warning-contrast);
font-size: 11px;
font-weight: 800;
letter-spacing: .3px;
@ -259,40 +241,6 @@ html { box-sizing: border-box; }
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;
line-height: 1.2;
vertical-align: middle;
text-transform: uppercase;
white-space: nowrap;
}
.courseToc .tocLink.disabled {
color: var(--text-muted);
background: transparent;
@ -303,70 +251,6 @@ 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 */
@ -564,11 +448,6 @@ body {
border-radius: var(--r-2);
}
/* Hide burger on large screens explicitly */
@media (min-width: 1025px) {
.nav-toggle { display: none !important; }
}
@media (max-width: 1024px) {
.site-nav {
position: sticky;
@ -590,7 +469,8 @@ body {
height: 100vh;
/* Ensure total box never exceeds the viewport width (padding + border included) */
box-sizing: border-box;
width: min(92vw, 380px);
width: min(85vw, 380px);
max-width: 100vw;
margin: 0;
/* Safe-area aware padding on the right (iOS notch) */
padding: var(--space-5) max(var(--gutter), env(safe-area-inset-right)) var(--space-5) var(--gutter);
@ -630,8 +510,8 @@ body {
z-index: 1100;
}
.navbar ul { flex-direction: column; width: 100%; align-items: flex-start; }
.navbar li { margin: 0; width: 100%; }
.navbar ul { flex-direction: column; width: 100%; }
.navbar li { margin: 0; }
.navbar a { display: block; padding: 12px 14px; border-radius: var(--r-1); }
/* Dropdowns behave as inline lists on mobile */
@ -644,22 +524,18 @@ body {
padding-left: 10px;
}
.navbar ul ul { align-items: flex-start; }
.navbar ul ul li { border: 0; margin: 0; padding: 6px 8px; width: 100%; }
.navbar ul ul a { text-align: left; }
.navbar ul ul li { border: 0; margin: 0; padding: 6px 8px; }
.navend {
width: 100%;
margin-top: auto; /* push to bottom */
display: flex;
flex-direction: column;
align-items: flex-start; /* start-align items on mobile */
justify-content: flex-start;
align-items: stretch;
gap: 6px;
}
.navend ul { width: 100%; align-items: flex-start; }
.navend a { text-align: left; }
.navend ul { width: 100%; }
}
/* Respect reduced motion preferences */
@ -774,18 +650,17 @@ section {
display: flex;
flex-direction: column;
padding: var(--space-6) var(--gutter);
width: 100%;
max-width: var(--container-w);
margin: clamp(12px, 3vw, 24px) auto;
width: min(100%, 60%);
margin: 20px auto;
}
.courseNav {
display: block;
float: left;
position: sticky;
position: fixed;
left: 0;
top: clamp(64px, 12vh, 150px);
max-width: clamp(220px, 22vw, 300px);
top: 150px;
max-width: 15%;
border: 1px solid;
}
@ -813,142 +688,23 @@ section {
font-size: 1.5rem;
}
/* --- Headings system (H1H6) --- */
/* Base: consistent rhythm, weight, and accessibility across themes */
:where(h1,h2,h3,h4,h5,h6) {
font-family: var(--font-sans);
font-weight: 700;
line-height: 1.2;
margin: 0 0 var(--space-4);
color: var(--fg);
letter-spacing: -0.01em;
}
h1 {
font-size: clamp(2rem, 4.5vw, 2.75rem);
color: var(--primary);
border-left: 4px solid var(--accent);
padding-left: var(--space-3);
font-size: clamp(1.8rem, 4vw, 2.5rem);
border-left: 5px solid var(--accent);
border-bottom: 1px solid var(--accent);
padding-left: 10px;
}
h2 {
font-size: clamp(1.5rem, 3.5vw, 2rem);
color: var(--accent);
border-bottom: 1px solid var(--border);
padding-bottom: 6px;
margin-top: var(--space-6);
font-size: clamp(1.3rem, 3vw, 1.8rem);
}
h3 { font-size: clamp(1.25rem, 3vw, 1.5rem); color: var(--fg); }
h4 { font-size: clamp(1.1rem, 2.2vw, 1.25rem); color: var(--fg); }
h5 { font-size: clamp(1rem, 2vw, 1.1rem); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
h6 { font-size: 0.95rem; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: .06em; }
img {
max-width: 100%;
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; }
/* Responsive tables: wrap in a container if needed */
.table-responsive { width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch; }
.card-header img.thumbnails {
transition: transform 1s ease
}
@ -958,20 +714,21 @@ iframe, video { max-width: 100%; height: auto; }
}
.container-inline {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-5);
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 20px;
}
.card {
display: flex;
flex-direction: column;
width: 100%;
max-width: 250px;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
border-radius: var(--r-2);
overflow: hidden;
margin: 0;
margin: 20px;
background: var(--card);
box-shadow: var(--shadow-1);
transition: transform var(--transition-1), box-shadow var(--transition-1);
@ -1010,7 +767,7 @@ iframe, video { max-width: 100%; height: auto; }
.def-author {
display: flex;
padding: var(--space-3);
padding: 10px;
flex-direction: row;
align-items: center;
font-size: 0.8rem;
@ -1023,18 +780,18 @@ iframe, video { max-width: 100%; height: auto; }
}
pre {
border-radius: var(--r-2);
border-radius: 5px;
/* Ne dépasse pas du parent en largeur et défile si nécessaire */
max-width: clamp(320px, 100%, 800px);
max-width: 800px;
overflow: auto; /* scroll horizontal et vertical si besoin */
max-height: clamp(360px, 70vh, 800px);
max-height: 800px;
}
/* ====== Homepage enhancements ====== */
.hero {
display: block;
width: 100%;
padding: clamp(48px, 10vw, 96px) var(--gutter) clamp(32px, 6vw, 64px);
padding: 80px 20px 56px;
text-align: center;
background:
radial-gradient(1200px 600px at 50% -10%, rgba(78,158,214,0.20), transparent 60%),
@ -1043,7 +800,10 @@ pre {
position: relative;
}
.hero-inner { max-width: var(--container-w); margin: 0 auto; padding: 0 var(--gutter); }
.hero-inner {
max-width: 960px;
margin: 0 auto;
}
.hero-decor {
position: absolute;
@ -1056,12 +816,31 @@ pre {
opacity: .6;
}
.hero .hero-sub { font-size: clamp(1rem, 2.4vw, 1.125rem); font-weight: 500; margin: var(--space-3) auto 0; max-width: 65ch; }
.hero .hero-sub {
font-size: 1.125rem;
font-weight: 500;
margin: 10px auto 0;
max-width: 800px;
}
.badge-row { display: flex; gap: var(--space-3); justify-content: center; margin: var(--space-4) 0 var(--space-2); flex-wrap: wrap; }
.badge { display: inline-flex; align-items: center; gap: var(--space-2); padding: 6px 10px; border: 1px solid var(--border-subtle); border-radius: 999px; background: color-mix(in oklab, var(--card) 80%, transparent); font-size: .9rem; color: var(--text-muted); }
.badge-row { display: flex; gap: 10px; justify-content: center; margin: 18px 0 6px; flex-wrap: wrap; }
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--border-subtle);
border-radius: 999px;
background: color-mix(in oklab, var(--card) 80%, transparent);
font-size: .9rem;
color: var(--text-muted);
}
.hero-cta .button { padding: 12px 18px; font-size: 1rem; border-radius: var(--r-2); }
.hero-cta .button {
padding: 12px 18px;
font-size: 1rem;
border-radius: 6px;
}
.cta-primary {
/* Make primary CTA more visible */
@ -1148,12 +927,11 @@ pre {
}
.pricing-teaser {
width: 100%;
max-width: 820px;
margin: var(--space-3) auto var(--space-6);
width: 60%;
margin: 10px auto 40px;
border: 1px solid var(--border);
border-radius: var(--r-3);
padding: var(--space-5);
padding: 20px;
background: var(--card);
}
@ -1165,7 +943,7 @@ pre {
.carousel-track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(260px, 80%);
grid-auto-columns: 80%;
gap: var(--space-4);
overflow-x: auto;
scroll-snap-type: x mandatory;
@ -1189,7 +967,7 @@ pre {
display: grid;
gap: var(--space-4);
}
.testimonials-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--space-4); }
.testimonials-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-4); }
.testimonial {
background: var(--card);
border: 1px solid var(--border);
@ -2330,16 +2108,6 @@ 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; }
@ -2673,21 +2441,3 @@ ul.flash_messages li.success {
background-color: var(--success);
color: var(--success-contrast);
}
.message-warning {
color: var(--neutral-900);
background: var(--neutral-200);
border: 1px solid var(--warning);
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
}
.message-info {
color: var(--neutral-900);
background: var(--neutral-200);
border: 1px solid var(--primary);
padding: 10px;
border-radius: 5px;
margin-bottom: 10px;
}

View file

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

View file

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

View file

@ -13,85 +13,3 @@ function show(id) {
let buttonChange = document.getElementById(id);
buttonChange.onclick = function() { hide(id); };
}
// Fonction pour supprimer le message d'alerte après 5 secondes
document.addEventListener('DOMContentLoaded', function() {
let messages = document.querySelector('.flash_messages')
if (messages) {
setTimeout(function() {
messages.style.opacity = 0;
setTimeout(function() {
messages.style.display = 'none';
}, 1000);
}, 5000);
}
// Theme toggle setup
var toggle = document.getElementById('themeToggle');
var themeLink = document.getElementById('theme-css');
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'light') {
if (themeLink) themeLink.href = themeLink.href.replace('colors_dark.css', 'colors_light.css');
} else {
if (themeLink) themeLink.href = themeLink.href.replace('colors_light.css', 'colors_dark.css');
}
var icon = toggle && toggle.querySelector('i');
if (icon) {
icon.classList.remove('fa-sun','fa-moon');
icon.classList.add(theme === 'light' ? 'fa-moon' : 'fa-sun');
}
if (toggle) {
toggle.setAttribute('aria-pressed', theme === 'dark' ? 'true' : 'false');
toggle.setAttribute('aria-label', theme === 'light' ? 'Activer le thème sombre' : 'Activer le thème clair');
toggle.title = toggle.getAttribute('aria-label');
}
}
try {
var current = document.documentElement.getAttribute('data-theme') || 'dark';
applyTheme(current);
if (toggle) {
toggle.addEventListener('click', function() {
var next = (document.documentElement.getAttribute('data-theme') === 'light') ? 'dark' : 'light';
localStorage.setItem('pdz-theme', next);
applyTheme(next);
});
}
} catch(e) {}
// Mobile nav toggle
try {
var navToggle = document.getElementById('navToggle');
var primaryNav = document.getElementById('primaryNav');
if (navToggle && primaryNav) {
function setExpanded(expanded) {
navToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
navToggle.setAttribute('aria-label', expanded ? 'Fermer le menu' : 'Ouvrir le menu');
var icon = navToggle.querySelector('i');
if (icon) {
icon.classList.remove('fa-bars','fa-xmark');
icon.classList.add(expanded ? 'fa-xmark' : 'fa-bars');
}
primaryNav.classList.toggle('is-open', expanded);
}
navToggle.addEventListener('click', function() {
var expanded = navToggle.getAttribute('aria-expanded') === 'true';
setExpanded(!expanded);
});
// Close menu when a link is clicked (on small screens)
primaryNav.addEventListener('click', function(e) {
var target = e.target;
if (target.tagName === 'A' || target.closest('a')) {
setExpanded(false);
}
});
// Close on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') setExpanded(false);
});
}
} catch(e) {}
});

View file

@ -1,25 +0,0 @@
{% 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 %}

View file

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

View file

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

View file

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

View file

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

View file

@ -6,7 +6,7 @@
<div class="hero-inner hero-split">
<div class="hero-text">
<h1>Apprendre le développement de zéro : Cours complets pour débutant (ou pas !)</h1>
<h1>Apprenez à coder de A à Z</h1>
<p class="hero-sub">Des cours gratuits<!-- et payants-->, structurés et concrets, pour progresser rapidement en programmation.</p>
<div class="badge-row" aria-hidden="true">
<span class="badge"><i class="fa-solid fa-code"></i> Logiciel, Web, Jeux Vidéos</span>

View file

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

View file

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

View file

@ -6,7 +6,7 @@
{% load static %}
<title>PartirDeZero {% block title %}{% endblock %}</title>
<title>PartirDeZero - {% block title %}{% endblock %}</title>
<meta name="description" content="{% block description %}Apprendre le développement web et la programmation en partant de zéro.{% endblock %}">
@ -31,37 +31,6 @@
<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>
@ -70,8 +39,8 @@
(function() {
try {
var stored = localStorage.getItem('pdz-theme');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
var theme = stored || (prefersLight ? 'dark' : 'light');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var theme = stored || (prefersLight ? 'light' : 'dark');
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'light') {
var link = document.getElementById('theme-css');
@ -84,33 +53,24 @@
<script defer>hljs.highlightAll();</script>
</head>
<body>
{% if maintenance.is_active == True %}
{% include "maintenance.html" %}
{% else %}
{% now "n" as month %}
{% if month == '12' %}
<!-- Overlay neige discret, non interactif -->
<div class="pdz-snow" aria-hidden="true"></div>
{% block header %}
{% include "partials/_header.html" %}
{% endblock %}
<main>
{% if messages %}
<ul class="flash_messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% block header %}
{% include "partials/_header.html" %}
{% endblock %}
<main>
{% if messages %}
<ul class="flash_messages">
{% for message in messages %}
<li{% if message.tags %} class="{{ message.tags }}"{% endif %}>{{ message }}</li>
{% endfor %}
</ul>
{% endif %}
{% block content %}{% endblock %}
</main>
{% block content %}{% endblock %}
</main>
{% block footer %}
{% include "partials/_footer.html" %}
{% endblock %}
{% endif %}
{% block footer %}
{% include "partials/_footer.html" %}
{% endblock %}
</body>
</html>

View file

@ -1,28 +0,0 @@
{% load comment_format %}
<section>
<h1>Maintenance : {{ maintenance.name }}</h1>
{{ maintenance.message|comment_markdown }}
<div class="text-right">Durée estimée : {{ delay }}</div>
<div class="text-right">Début de la maintenance : {{ maintenance.start_date }}</div>
<div class="text-right">Fin de la maintenance estimé : {{ maintenance.end_date }}</div>
{% if message %}
<h2>{{ message }}</h2>
{% endif %}
{% if user.is_superuser %}
<div style="border-bottom: 2px solid white"></div>
<div>
<ul>
<li><a href="{% url 'update_database' %}">Mettre à jour la base de données</a></li>
<li><a href="">Nettoyer la base de données</a></li>
<li><a href="{% url 'clear_cache' %}">Effacer le cache</a></li>
<li><a href="{% url 'regen_static_files' %}">Régénérer les fichiers static</a></li>
<div style="border-bottom: 2px solid white; margin: 5px;"></div>
<li><a href="{% url 'admin:index' %}" class="btn btn-warning" target="_blank">Panel Admin</a></li>
</ul>
</div>
<div class="message-warning"><i class="fa-solid fa-terminal" style="color:orange;border-right: 1px solid orange; padding: 5px;"> </i><strong> Commande de redemarrage serveur : </strong>kill -HUP $(cat /var/www/vhosts/partirdezero.com/httpdocs/run/gunicorn.pid)</div>
<div class="message-info"><i class="fa-solid fa-link" style="color:orange;border-right: 1px solid orange; padding: 5px;"> </i> <a href="https://trusting-moser.82-165-125-100.plesk.page:8443/modules/ssh-terminal/" target="_blank">Accès terminal SSH</a></div>
{% endif %}
</section>

View file

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

View file

@ -2,12 +2,7 @@
<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
{% now "n" as month %}
{% if month == '12' %}
<span class="pdz-festive-emoji" aria-hidden="true" title="Joyeuses fêtes">🎄</span>
{% endif %}
</span>
<span class="site-title">Partir de zéro</span>
</div>
<span class="subtitle comment">/* Anthony Violet */</span>
</div>
@ -25,8 +20,8 @@
{% endfor %}
</ul>
</li>
<!--<li><a href="">Tutoriels</a></li>-->
<li><a href="{% url 'blog:blog' %}">Blog</a></li>
<!--<li><a href="">Tutoriels</a></li>
<li><a href="">Billets</a></li>-->
</ul>
<div class="navend">
<ul>
@ -48,7 +43,6 @@
{% if user.is_authenticated and user.is_staff %}
<li>
<a href="{% url 'admin:index' %}" class="button button-danger" title="Administration">Admin</a>
<a href="{% url 'home:stats_dashboard' %}" class="button button-warning" title="Stats">Stats</a>
</li>
{% endif %}
<li>

View file

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

View file

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

View file

@ -6,27 +6,12 @@
{% endblock %}
<div class="profile-details">
<h2>Mes cours</h2>
<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 %}
<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>
{% endfor %}
</ul>
</div>
</section>
{% endblock %}

View file

@ -22,27 +22,24 @@
<div class="profile-card">
<h3>Mes cours</h3>
{% with progress_list=latest_progress %}
{% if progress_list %}
<ul class="profile-courses">
{% 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">
<a class="btn btn-secondary btn-sm" href="{% url 'user_courses' %}">Voir tous mes cours</a>
</div>
{% else %}
<p class="muted">Aucun cours suivi pour le moment.</p>
{% endif %}
{% with courses=user.course_set.all %}
{% if courses %}
<ul class="profile-courses">
{% for course in courses|slice:":6" %}
<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>
</li>
{% endfor %}
</ul>
<div class="text-right">
<a class="btn btn-secondary btn-sm" href="{% url 'user_courses' %}">Voir tous mes cours</a>
</div>
{% else %}
<p class="muted">Aucun cours suivi pour le moment.</p>
{% endif %}
{% endwith %}
</div>

View file

@ -27,15 +27,7 @@ class UserRegistrationForm(forms.Form):
password2 = cleaned_data.get("password2")
if password1 and password2 and password1 != password2:
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
raise forms.ValidationError("Passwords do not match")
class UserLoginForm(forms.Form):
username = forms.CharField(

View file

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

View file

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

View file

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