From 8638273475ac90530ae0a4da9a6e09c4048aa701 Mon Sep 17 00:00:00 2001 From: ifaryd Date: Tue, 12 May 2026 16:54:38 +0000 Subject: [PATCH] Initial commit --- .dockerignore | 53 + .gitignore | 41 + Dockerfile | 39 + apps/__init__.py | 0 apps/careers/__init__.py | 0 apps/careers/admin.py | 72 + apps/careers/apps.py | 9 + apps/careers/forms.py | 50 + apps/careers/migrations/0001_initial.py | 66 + apps/careers/migrations/__init__.py | 0 apps/careers/models.py | 103 + apps/careers/signals.py | 47 + apps/careers/tests.py | 3 + apps/careers/urls.py | 11 + apps/careers/views.py | 67 + apps/core/__init__.py | 0 apps/core/admin.py | 15 + apps/core/apps.py | 6 + apps/core/context_processors.py | 15 + apps/core/forms.py | 29 + apps/core/migrations/0001_initial.py | 32 + apps/core/migrations/__init__.py | 0 apps/core/models.py | 19 + apps/core/tests.py | 3 + apps/core/urls.py | 17 + apps/core/views.py | 75 + config/__init__.py | 0 config/asgi.py | 16 + config/settings/__init__.py | 0 config/settings/base.py | 83 + config/settings/dev.py | 29 + config/settings/prod.py | 61 + config/urls.py | 32 + config/wsgi.py | 16 + docker-compose.yml | 69 + export_pages.py | 142 ++ manage.py | 22 + nginx/nginx.conf | 113 ++ requirements.txt | 6 + static/css/careers.css | 533 +++++ static/css/home.css | 1753 +++++++++++++++++ static/img/JooL Monitor.jpg | Bin 0 -> 525744 bytes static/img/Kiriq AI.jpg | Bin 0 -> 408946 bytes static/img/apple-touch-icon.png | Bin 0 -> 10502 bytes static/img/favicon-16x16.png | Bin 0 -> 511 bytes static/img/favicon-32x32.png | Bin 0 -> 1296 bytes static/img/favicon.ico | Bin 0 -> 533 bytes static/img/icon-192.png | Bin 0 -> 11377 bytes static/img/icon-512.png | Bin 0 -> 33142 bytes static/img/logo (2).png | Bin 0 -> 122258 bytes static/img/logo.png | Bin 0 -> 122258 bytes static/img/partenaires/Archetyp.jpeg | Bin 0 -> 3690 bytes static/img/partenaires/Tony's_Chocolonely.svg | 463 +++++ static/img/partenaires/apromac.png | Bin 0 -> 20103 bytes static/img/partenaires/pmci.jpeg | Bin 0 -> 10693 bytes static/img/partenaires/sodexam.png | Bin 0 -> 4525 bytes static/img/partenaires/tonys_chocolonely.svg | 463 +++++ static/img/partenaires/trci.jpg | Bin 0 -> 5299 bytes static/js/all.js | 21 + static/js/faq_toggle.js | 3 + static/js/hamburger.js | 20 + static/js/scroll_reveal.js | 10 + templates/base.html | 86 + templates/careers/apply.html | 90 + templates/careers/apply_success.html | 22 + .../emails/application_received_body.txt | 12 + .../emails/application_received_subject.txt | 1 + .../emails/new_application_admin_body.txt | 16 + .../emails/new_application_admin_subject.txt | 1 + templates/careers/job_detail.html | 63 + templates/careers/job_list.html | 50 + templates/core/about.html | 370 ++++ templates/core/home.html | 84 + templates/core/partials/_cta_final.html | 82 + templates/core/partials/_faq.html | 68 + templates/core/partials/_features.html | 43 + templates/core/partials/_footer.html | 43 + templates/core/partials/_hero.html | 56 + templates/core/partials/_nav.html | 47 + templates/core/partials/_section_joolid.html | 98 + templates/core/partials/_section_kiriq.html | 59 + templates/core/partials/_section_monitor.html | 61 + templates/core/partials/_stats.html | 27 + templates/core/partials/_testimonial.html | 13 + templates/core/partials/_trust_strip.html | 7 + templates/core/partials/_trusted_by.html | 28 + templates/core/privacy.html | 232 +++ templates/core/products/joolid.html | 163 ++ templates/core/products/kiriq.html | 192 ++ templates/core/products/monitor.html | 179 ++ templates/robots.txt | 7 + templates/sitemap.xml | 34 + 92 files changed, 6861 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 apps/__init__.py create mode 100644 apps/careers/__init__.py create mode 100644 apps/careers/admin.py create mode 100644 apps/careers/apps.py create mode 100644 apps/careers/forms.py create mode 100644 apps/careers/migrations/0001_initial.py create mode 100644 apps/careers/migrations/__init__.py create mode 100644 apps/careers/models.py create mode 100644 apps/careers/signals.py create mode 100644 apps/careers/tests.py create mode 100644 apps/careers/urls.py create mode 100644 apps/careers/views.py create mode 100644 apps/core/__init__.py create mode 100644 apps/core/admin.py create mode 100644 apps/core/apps.py create mode 100644 apps/core/context_processors.py create mode 100644 apps/core/forms.py create mode 100644 apps/core/migrations/0001_initial.py create mode 100644 apps/core/migrations/__init__.py create mode 100644 apps/core/models.py create mode 100644 apps/core/tests.py create mode 100644 apps/core/urls.py create mode 100644 apps/core/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/dev.py create mode 100644 config/settings/prod.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 docker-compose.yml create mode 100644 export_pages.py create mode 100755 manage.py create mode 100644 nginx/nginx.conf create mode 100644 requirements.txt create mode 100644 static/css/careers.css create mode 100644 static/css/home.css create mode 100644 static/img/JooL Monitor.jpg create mode 100644 static/img/Kiriq AI.jpg create mode 100644 static/img/apple-touch-icon.png create mode 100644 static/img/favicon-16x16.png create mode 100644 static/img/favicon-32x32.png create mode 100644 static/img/favicon.ico create mode 100644 static/img/icon-192.png create mode 100644 static/img/icon-512.png create mode 100644 static/img/logo (2).png create mode 100644 static/img/logo.png create mode 100644 static/img/partenaires/Archetyp.jpeg create mode 100644 static/img/partenaires/Tony's_Chocolonely.svg create mode 100644 static/img/partenaires/apromac.png create mode 100644 static/img/partenaires/pmci.jpeg create mode 100644 static/img/partenaires/sodexam.png create mode 100644 static/img/partenaires/tonys_chocolonely.svg create mode 100644 static/img/partenaires/trci.jpg create mode 100644 static/js/all.js create mode 100644 static/js/faq_toggle.js create mode 100644 static/js/hamburger.js create mode 100644 static/js/scroll_reveal.js create mode 100644 templates/base.html create mode 100644 templates/careers/apply.html create mode 100644 templates/careers/apply_success.html create mode 100644 templates/careers/emails/application_received_body.txt create mode 100644 templates/careers/emails/application_received_subject.txt create mode 100644 templates/careers/emails/new_application_admin_body.txt create mode 100644 templates/careers/emails/new_application_admin_subject.txt create mode 100644 templates/careers/job_detail.html create mode 100644 templates/careers/job_list.html create mode 100644 templates/core/about.html create mode 100644 templates/core/home.html create mode 100644 templates/core/partials/_cta_final.html create mode 100644 templates/core/partials/_faq.html create mode 100644 templates/core/partials/_features.html create mode 100644 templates/core/partials/_footer.html create mode 100644 templates/core/partials/_hero.html create mode 100644 templates/core/partials/_nav.html create mode 100644 templates/core/partials/_section_joolid.html create mode 100644 templates/core/partials/_section_kiriq.html create mode 100644 templates/core/partials/_section_monitor.html create mode 100644 templates/core/partials/_stats.html create mode 100644 templates/core/partials/_testimonial.html create mode 100644 templates/core/partials/_trust_strip.html create mode 100644 templates/core/partials/_trusted_by.html create mode 100644 templates/core/privacy.html create mode 100644 templates/core/products/joolid.html create mode 100644 templates/core/products/kiriq.html create mode 100644 templates/core/products/monitor.html create mode 100644 templates/robots.txt create mode 100644 templates/sitemap.xml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5c84579 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# Git +.git +.gitignore + +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.Python +*.egg-info/ +dist/ +build/ +*.egg + +# Environnements virtuels +.venv/ +venv/ +env/ + +# Variables d'environnement +.env +.env.* +!.env.prod + +# Base de données locale +*.sqlite3 +db.sqlite3 + +# Fichiers statiques compilés (générés dans le container) +staticfiles/ + +# Media uploads locaux +media/ + +# Tests / CI +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# Editeurs +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store + +# Figma export (pas nécessaire en prod) +figma_export/ + +# Logs +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c90b0ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +.Python +*.egg-info/ +dist/ +build/ + +# Environnements +.venv/ +venv/ +env/ + +# Variables d'environnement (NE JAMAIS committer) +.env +.env.prod +.env.local + +# Base de données locale +*.sqlite3 + +# Fichiers statiques compilés +staticfiles/ + +# Media uploads +media/ + +# Tests +.coverage +htmlcov/ +.pytest_cache/ + +# Editeurs +.idea/ +.vscode/ +*.swp +.DS_Store + +# Logs +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ef8a264 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM python:3.12-slim + +# Variables d'environnement +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + DJANGO_SETTINGS_MODULE=config.settings.prod + +WORKDIR /app + +# Dépendances système minimales +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Dépendances Python +COPY requirements.txt . +RUN pip install --upgrade pip && pip install -r requirements.txt + +# Code source +COPY . . + +# Collecte des fichiers statiques +# DATABASE_URL factice pour éviter l'erreur de connexion à la DB au build +RUN SECRET_KEY=build-only-key DATABASE_URL=sqlite:///tmp/build.db \ + python manage.py collectstatic --noinput + +# Utilisateur non-root pour la sécurité +RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app +USER appuser + +EXPOSE 8000 + +CMD ["gunicorn", "config.wsgi:application", \ + "--bind", "0.0.0.0:8000", \ + "--workers", "3", \ + "--timeout", "60", \ + "--access-logfile", "-", \ + "--error-logfile", "-"] diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/careers/__init__.py b/apps/careers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/careers/admin.py b/apps/careers/admin.py new file mode 100644 index 0000000..3bacceb --- /dev/null +++ b/apps/careers/admin.py @@ -0,0 +1,72 @@ +from django.contrib import admin +from django.utils import timezone + +from .models import JobApplication, JobOffer + + +@admin.register(JobOffer) +class JobOfferAdmin(admin.ModelAdmin): + list_display = ['title', 'department', 'contract_type', 'location', 'status', 'application_deadline', 'created_at'] + list_filter = ['status', 'department', 'contract_type', 'is_remote'] + search_fields = ['title', 'location', 'description'] + prepopulated_fields = {'slug': ('title',)} + readonly_fields = ['published_at', 'created_at', 'updated_at'] + list_editable = ['status'] + date_hierarchy = 'created_at' + actions = ['publish_offers', 'close_offers'] + fieldsets = ( + ("Informations générales", { + 'fields': ('title', 'slug', 'department', 'contract_type', 'location', 'is_remote', 'salary_range') + }), + ("Contenu de l'offre", { + 'fields': ('description', 'requirements', 'nice_to_have') + }), + ('Publication', { + 'fields': ('status', 'application_deadline', 'published_at', 'created_at', 'updated_at') + }), + ) + + @admin.action(description='Publier les offres sélectionnées') + def publish_offers(self, request, queryset): + queryset.update(status=JobOffer.Status.PUBLISHED, published_at=timezone.now()) + + @admin.action(description='Fermer les offres sélectionnées') + def close_offers(self, request, queryset): + queryset.update(status=JobOffer.Status.CLOSED) + + +@admin.register(JobApplication) +class JobApplicationAdmin(admin.ModelAdmin): + list_display = ['full_name_col', 'email', 'job', 'status', 'applied_at'] + list_filter = ['status', 'job__department', 'job'] + search_fields = ['first_name', 'last_name', 'email', 'job__title'] + readonly_fields = [ + 'first_name', 'last_name', 'email', 'phone', 'linkedin_url', + 'cover_letter', 'cv_file', 'portfolio_url', 'applied_at', 'job', + ] + list_editable = ['status'] + date_hierarchy = 'applied_at' + actions = ['mark_shortlisted', 'mark_rejected'] + fieldsets = ( + ('Candidat', { + 'fields': ('first_name', 'last_name', 'email', 'phone', 'linkedin_url', 'portfolio_url') + }), + ('Documents', { + 'fields': ('job', 'cv_file', 'cover_letter') + }), + ('Traitement RH', { + 'fields': ('status', 'admin_notes', 'applied_at') + }), + ) + + @admin.display(description='Candidat') + def full_name_col(self, obj): + return obj.full_name + + @admin.action(description='Marquer comme présélectionné(e)') + def mark_shortlisted(self, request, queryset): + queryset.update(status=JobApplication.Status.SHORTLIST) + + @admin.action(description='Marquer comme rejeté(e)') + def mark_rejected(self, request, queryset): + queryset.update(status=JobApplication.Status.REJECTED) diff --git a/apps/careers/apps.py b/apps/careers/apps.py new file mode 100644 index 0000000..3031ff1 --- /dev/null +++ b/apps/careers/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class CareersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.careers' + + def ready(self): + import apps.careers.signals # noqa diff --git a/apps/careers/forms.py b/apps/careers/forms.py new file mode 100644 index 0000000..e2ac87e --- /dev/null +++ b/apps/careers/forms.py @@ -0,0 +1,50 @@ +from django import forms +from django.core.validators import FileExtensionValidator +from .models import JobApplication + + +class JobApplicationForm(forms.ModelForm): + cv_file = forms.FileField( + validators=[FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx'])], + widget=forms.ClearableFileInput(attrs={ + 'accept': '.pdf,.doc,.docx', + 'class': 'form-file-input', + }), + label='CV (PDF ou Word)', + help_text='Formats acceptés : PDF, DOC, DOCX — taille maximum 5 Mo', + ) + + class Meta: + model = JobApplication + fields = [ + 'first_name', 'last_name', 'email', 'phone', + 'linkedin_url', 'cover_letter', 'cv_file', 'portfolio_url', + ] + labels = { + 'first_name': 'Prénom', + 'last_name': 'Nom de famille', + 'email': 'Adresse email', + 'phone': 'Téléphone (optionnel)', + 'linkedin_url': 'LinkedIn (optionnel)', + 'cover_letter': 'Lettre de motivation', + 'portfolio_url': 'Portfolio / GitHub (optionnel)', + } + widgets = { + 'first_name': forms.TextInput(attrs={'placeholder': 'Votre prénom', 'class': 'form-input'}), + 'last_name': forms.TextInput(attrs={'placeholder': 'Votre nom', 'class': 'form-input'}), + 'email': forms.EmailInput(attrs={'placeholder': 'vous@exemple.com', 'class': 'form-input'}), + 'phone': forms.TextInput(attrs={'placeholder': '+225 07 00 00 00 00', 'class': 'form-input'}), + 'linkedin_url': forms.URLInput(attrs={'placeholder': 'https://linkedin.com/in/...', 'class': 'form-input'}), + 'cover_letter': forms.Textarea(attrs={ + 'rows': 8, + 'class': 'form-textarea', + 'placeholder': 'Décrivez votre motivation et vos atouts pour ce poste...', + }), + 'portfolio_url': forms.URLInput(attrs={'placeholder': 'https://github.com/...', 'class': 'form-input'}), + } + + def clean_cv_file(self): + cv = self.cleaned_data.get('cv_file') + if cv and cv.size > 5 * 1024 * 1024: + raise forms.ValidationError('Le fichier CV ne peut pas dépasser 5 Mo.') + return cv diff --git a/apps/careers/migrations/0001_initial.py b/apps/careers/migrations/0001_initial.py new file mode 100644 index 0000000..72bf340 --- /dev/null +++ b/apps/careers/migrations/0001_initial.py @@ -0,0 +1,66 @@ +# Generated by Django 5.1.15 on 2026-04-13 12:26 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='JobOffer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='Intitulé du poste')), + ('slug', models.SlugField(max_length=220, unique=True)), + ('department', models.CharField(choices=[('tech', 'Technologie'), ('agro', 'Agronomie'), ('data', 'Data & IA'), ('ops', 'Opérations'), ('sales', 'Commercial'), ('admin', 'Administration')], max_length=20, verbose_name='Département')), + ('contract_type', models.CharField(choices=[('CDI', 'CDI'), ('CDD', 'CDD'), ('stage', 'Stage'), ('freelance', 'Freelance')], max_length=20, verbose_name='Type de contrat')), + ('location', models.CharField(default="Abidjan, Côte d'Ivoire", max_length=150, verbose_name='Lieu')), + ('is_remote', models.BooleanField(default=False, verbose_name='Télétravail possible')), + ('description', models.TextField(verbose_name='Description du poste')), + ('requirements', models.TextField(verbose_name='Compétences requises')), + ('nice_to_have', models.TextField(blank=True, verbose_name='Compétences souhaitées')), + ('salary_range', models.CharField(blank=True, max_length=100, verbose_name='Fourchette salariale')), + ('status', models.CharField(choices=[('draft', 'Brouillon'), ('published', 'Publié'), ('closed', 'Fermé')], db_index=True, default='draft', max_length=20, verbose_name='Statut')), + ('application_deadline', models.DateField(blank=True, null=True, verbose_name='Date limite de candidature')), + ('published_at', models.DateTimeField(blank=True, null=True, verbose_name='Publié le')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': "Offre d'emploi", + 'verbose_name_plural': "Offres d'emploi", + 'ordering': ['-published_at', '-created_at'], + }, + ), + migrations.CreateModel( + name='JobApplication', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=100, verbose_name='Prénom')), + ('last_name', models.CharField(max_length=100, verbose_name='Nom de famille')), + ('email', models.EmailField(max_length=254, verbose_name='Email')), + ('phone', models.CharField(blank=True, max_length=30, verbose_name='Téléphone')), + ('linkedin_url', models.URLField(blank=True, verbose_name='LinkedIn')), + ('cover_letter', models.TextField(verbose_name='Lettre de motivation')), + ('cv_file', models.FileField(upload_to='careers/cvs/%Y/%m/', verbose_name='CV')), + ('portfolio_url', models.URLField(blank=True, verbose_name='Portfolio / GitHub')), + ('status', models.CharField(choices=[('new', 'Nouvelle'), ('reviewing', "En cours d'examen"), ('shortlist', 'Présélectionné(e)'), ('interview', 'Entretien planifié'), ('rejected', 'Rejeté(e)'), ('hired', 'Recruté(e)')], db_index=True, default='new', max_length=20, verbose_name='Statut')), + ('admin_notes', models.TextField(blank=True, verbose_name='Notes internes')), + ('applied_at', models.DateTimeField(auto_now_add=True, verbose_name='Candidaté le')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='careers.joboffer', verbose_name='Offre')), + ], + options={ + 'verbose_name': 'Candidature', + 'verbose_name_plural': 'Candidatures', + 'ordering': ['-applied_at'], + 'unique_together': {('job', 'email')}, + }, + ), + ] diff --git a/apps/careers/migrations/__init__.py b/apps/careers/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/careers/models.py b/apps/careers/models.py new file mode 100644 index 0000000..50cf4b9 --- /dev/null +++ b/apps/careers/models.py @@ -0,0 +1,103 @@ +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.text import slugify + + +class JobOffer(models.Model): + class Status(models.TextChoices): + DRAFT = 'draft', 'Brouillon' + PUBLISHED = 'published', 'Publié' + CLOSED = 'closed', 'Fermé' + + class ContractType(models.TextChoices): + CDI = 'CDI', 'CDI' + CDD = 'CDD', 'CDD' + STAGE = 'stage', 'Stage' + FREELANCE = 'freelance', 'Freelance' + + class Department(models.TextChoices): + TECH = 'tech', 'Technologie' + AGRO = 'agro', 'Agronomie' + OPS = 'ops', 'Opérations' + SALES = 'sales', 'Commercial' + ADMIN = 'admin', 'Administration' + + title = models.CharField(max_length=200, verbose_name='Intitulé du poste') + slug = models.SlugField(max_length=220, unique=True) + department = models.CharField(max_length=20, choices=Department.choices, verbose_name='Département') + contract_type = models.CharField(max_length=20, choices=ContractType.choices, verbose_name='Type de contrat') + location = models.CharField(max_length=150, default="Abidjan, Côte d'Ivoire", verbose_name='Lieu') + is_remote = models.BooleanField(default=False, verbose_name='Télétravail possible') + description = models.TextField(verbose_name='Description du poste') + requirements = models.TextField(verbose_name='Compétences requises') + nice_to_have = models.TextField(blank=True, verbose_name='Compétences souhaitées') + salary_range = models.CharField(max_length=100, blank=True, verbose_name='Fourchette salariale') + status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT, db_index=True, verbose_name='Statut') + application_deadline = models.DateField(null=True, blank=True, verbose_name='Date limite de candidature') + published_at = models.DateTimeField(null=True, blank=True, verbose_name='Publié le') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-published_at', '-created_at'] + verbose_name = "Offre d'emploi" + verbose_name_plural = "Offres d'emploi" + + def __str__(self): + return f"{self.title} ({self.get_contract_type_display()})" + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + if self.status == self.Status.PUBLISHED and not self.published_at: + self.published_at = timezone.now() + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse('careers:job_detail', kwargs={'slug': self.slug}) + + @property + def is_open(self): + if self.status != self.Status.PUBLISHED: + return False + if self.application_deadline and self.application_deadline < timezone.now().date(): + return False + return True + + +class JobApplication(models.Model): + class Status(models.TextChoices): + NEW = 'new', 'Nouvelle' + REVIEWING = 'reviewing', "En cours d'examen" + SHORTLIST = 'shortlist', 'Présélectionné(e)' + INTERVIEW = 'interview', 'Entretien planifié' + REJECTED = 'rejected', 'Rejeté(e)' + HIRED = 'hired', 'Recruté(e)' + + job = models.ForeignKey(JobOffer, on_delete=models.CASCADE, related_name='applications', verbose_name='Offre') + first_name = models.CharField(max_length=100, verbose_name='Prénom') + last_name = models.CharField(max_length=100, verbose_name='Nom de famille') + email = models.EmailField(verbose_name='Email') + phone = models.CharField(max_length=30, blank=True, verbose_name='Téléphone') + linkedin_url = models.URLField(blank=True, verbose_name='LinkedIn') + cover_letter = models.TextField(verbose_name='Lettre de motivation') + cv_file = models.FileField(upload_to='careers/cvs/%Y/%m/', verbose_name='CV') + portfolio_url = models.URLField(blank=True, verbose_name='Portfolio / GitHub') + status = models.CharField(max_length=20, choices=Status.choices, default=Status.NEW, db_index=True, verbose_name='Statut') + admin_notes = models.TextField(blank=True, verbose_name='Notes internes') + applied_at = models.DateTimeField(auto_now_add=True, verbose_name='Candidaté le') + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-applied_at'] + verbose_name = 'Candidature' + verbose_name_plural = 'Candidatures' + unique_together = [('job', 'email')] + + def __str__(self): + return f"{self.first_name} {self.last_name} → {self.job.title}" + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}" diff --git a/apps/careers/signals.py b/apps/careers/signals.py new file mode 100644 index 0000000..819cbe8 --- /dev/null +++ b/apps/careers/signals.py @@ -0,0 +1,47 @@ +from django.conf import settings +from django.core.mail import send_mail +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.template.loader import render_to_string + +from .models import JobApplication + + +@receiver(post_save, sender=JobApplication) +def on_application_created(sender, instance, created, **kwargs): + if not created: + return + + # Email to applicant + subject_applicant = render_to_string( + 'careers/emails/application_received_subject.txt', + {'application': instance} + ).strip() + body_applicant = render_to_string( + 'careers/emails/application_received_body.txt', + {'application': instance} + ) + send_mail( + subject=subject_applicant, + message=body_applicant, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[instance.email], + fail_silently=True, + ) + + # Email to HR + subject_hr = render_to_string( + 'careers/emails/new_application_admin_subject.txt', + {'application': instance} + ).strip() + body_hr = render_to_string( + 'careers/emails/new_application_admin_body.txt', + {'application': instance} + ) + send_mail( + subject=subject_hr, + message=body_hr, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[settings.CAREERS_NOTIFY_EMAIL], + fail_silently=True, + ) diff --git a/apps/careers/tests.py b/apps/careers/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/careers/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/careers/urls.py b/apps/careers/urls.py new file mode 100644 index 0000000..8e5abc8 --- /dev/null +++ b/apps/careers/urls.py @@ -0,0 +1,11 @@ +from django.urls import path +from . import views + +app_name = 'careers' + +urlpatterns = [ + path('', views.JobListView.as_view(), name='job_list'), + path('/', views.JobDetailView.as_view(), name='job_detail'), + path('/postuler/', views.ApplyView.as_view(), name='apply'), + path('/postuler/confirmation/', views.ApplySuccessView.as_view(), name='apply_success'), +] diff --git a/apps/careers/views.py b/apps/careers/views.py new file mode 100644 index 0000000..a3263b3 --- /dev/null +++ b/apps/careers/views.py @@ -0,0 +1,67 @@ +from django.db import IntegrityError +from django.shortcuts import get_object_or_404, redirect +from django.views.generic import DetailView, FormView, ListView, TemplateView + +from .forms import JobApplicationForm +from .models import JobApplication, JobOffer + + +class JobListView(ListView): + template_name = 'careers/job_list.html' + context_object_name = 'jobs' + + def get_queryset(self): + qs = JobOffer.objects.filter(status=JobOffer.Status.PUBLISHED) + dept = self.request.GET.get('dept') + if dept: + qs = qs.filter(department=dept) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['departments'] = JobOffer.Department.choices + ctx['active_dept'] = self.request.GET.get('dept', '') + return ctx + + +class JobDetailView(DetailView): + template_name = 'careers/job_detail.html' + context_object_name = 'job' + + def get_object(self): + return get_object_or_404(JobOffer, slug=self.kwargs['slug'], status=JobOffer.Status.PUBLISHED) + + +class ApplyView(FormView): + template_name = 'careers/apply.html' + form_class = JobApplicationForm + + def dispatch(self, request, *args, **kwargs): + self.job = get_object_or_404(JobOffer, slug=kwargs['slug'], status=JobOffer.Status.PUBLISHED) + if not self.job.is_open: + return redirect('careers:job_detail', slug=self.job.slug) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['job'] = self.job + return ctx + + def form_valid(self, form): + application = form.save(commit=False) + application.job = self.job + try: + application.save() + except IntegrityError: + form.add_error('email', 'Vous avez déjà postulé à cette offre avec cet email.') + return self.form_invalid(form) + return redirect('careers:apply_success', slug=self.job.slug) + + +class ApplySuccessView(TemplateView): + template_name = 'careers/apply_success.html' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['job'] = get_object_or_404(JobOffer, slug=self.kwargs['slug']) + return ctx diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/admin.py b/apps/core/admin.py new file mode 100644 index 0000000..7f683a4 --- /dev/null +++ b/apps/core/admin.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from .models import ContactRequest + + +@admin.register(ContactRequest) +class ContactRequestAdmin(admin.ModelAdmin): + list_display = ['last_name', 'first_name', 'email', 'phone', 'created_at', 'is_read'] + list_filter = ['is_read', 'created_at'] + search_fields = ['last_name', 'first_name', 'email', 'message'] + list_editable = ['is_read'] + readonly_fields = ['last_name', 'first_name', 'email', 'phone', 'message', 'created_at'] + ordering = ['-created_at'] + + def has_add_permission(self, request): + return False diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..4143768 --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.core' diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py new file mode 100644 index 0000000..20fcaaa --- /dev/null +++ b/apps/core/context_processors.py @@ -0,0 +1,15 @@ +from django.conf import settings + + +def site_context(request): + careers_enabled = getattr(settings, 'CAREERS_ENABLED', False) + open_jobs_count = 0 + + if careers_enabled: + from apps.careers.models import JobOffer + open_jobs_count = JobOffer.objects.filter(status=JobOffer.Status.PUBLISHED).count() + + return { + 'careers_enabled': careers_enabled, + 'open_jobs_count': open_jobs_count, + } diff --git a/apps/core/forms.py b/apps/core/forms.py new file mode 100644 index 0000000..c7e3669 --- /dev/null +++ b/apps/core/forms.py @@ -0,0 +1,29 @@ +import re +from django import forms +from .models import ContactRequest + + +class ContactForm(forms.ModelForm): + class Meta: + model = ContactRequest + fields = ['last_name', 'first_name', 'email', 'phone', 'message'] + widgets = { + 'last_name': forms.TextInput(attrs={'placeholder': 'Nom', 'class': 'cta-input'}), + 'first_name': forms.TextInput(attrs={'placeholder': 'Prénom', 'class': 'cta-input'}), + 'email': forms.EmailInput(attrs={'placeholder': 'Adresse email', 'class': 'cta-input'}), + 'phone': forms.TextInput(attrs={'placeholder': 'Téléphone', 'class': 'cta-input', 'inputmode': 'numeric', 'pattern': '[0-9+ ]{6,20}'}), + 'message': forms.Textarea(attrs={'placeholder': 'Votre demande…', 'class': 'cta-input cta-textarea', 'rows': 4}), + } + labels = { + 'last_name': '', + 'first_name': '', + 'email': '', + 'phone': '', + 'message': '', + } + + def clean_phone(self): + phone = self.cleaned_data.get('phone', '').strip() + if phone and not re.fullmatch(r'[0-9+\s\-]{6,20}', phone): + raise forms.ValidationError('Numéro invalide — chiffres uniquement.') + return phone diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py new file mode 100644 index 0000000..cb7ecf6 --- /dev/null +++ b/apps/core/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.25 on 2026-04-14 10:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ContactRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=100, verbose_name='Prénom')), + ('last_name', models.CharField(max_length=100, verbose_name='Nom')), + ('email', models.EmailField(max_length=254, verbose_name='Email')), + ('phone', models.CharField(blank=True, max_length=20, verbose_name='Téléphone')), + ('message', models.TextField(verbose_name='Demande')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Reçu le')), + ('is_read', models.BooleanField(default=False, verbose_name='Lu')), + ], + options={ + 'verbose_name': 'Demande de contact', + 'verbose_name_plural': 'Demandes de contact', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/models.py b/apps/core/models.py new file mode 100644 index 0000000..901b300 --- /dev/null +++ b/apps/core/models.py @@ -0,0 +1,19 @@ +from django.db import models + + +class ContactRequest(models.Model): + first_name = models.CharField('Prénom', max_length=100) + last_name = models.CharField('Nom', max_length=100) + email = models.EmailField('Email') + phone = models.CharField('Téléphone', max_length=20, blank=True) + message = models.TextField('Demande') + created_at = models.DateTimeField('Reçu le', auto_now_add=True) + is_read = models.BooleanField('Lu', default=False) + + class Meta: + verbose_name = 'Demande de contact' + verbose_name_plural = 'Demandes de contact' + ordering = ['-created_at'] + + def __str__(self): + return f"{self.first_name} {self.last_name} — {self.email}" diff --git a/apps/core/tests.py b/apps/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/core/urls.py b/apps/core/urls.py new file mode 100644 index 0000000..def6fca --- /dev/null +++ b/apps/core/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from . import views + +app_name = 'core' + +urlpatterns = [ + path('', views.HomeView.as_view(), name='home'), + path('contact/', views.ContactAjaxView.as_view(), name='contact_ajax'), + path('a-propos/', views.AboutView.as_view(), name='about'), + path('produits/kiriq/', views.KiriqView.as_view(), name='kiriq'), + path('produits/monitor/', views.MonitorView.as_view(), name='monitor'), + path('produits/joolid/', views.JoolidView.as_view(), name='joolid'), + path('confidentialite/', views.PrivacyView.as_view(), name='privacy'), + # SEO + path('robots.txt', views.RobotsTxtView.as_view(), name='robots_txt'), + path('sitemap.xml', views.SitemapXmlView.as_view(), name='sitemap_xml'), +] diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..5cdcffd --- /dev/null +++ b/apps/core/views.py @@ -0,0 +1,75 @@ +import logging +from django.views.generic import TemplateView +from django.views import View +from django.http import JsonResponse +from django.core.mail import send_mail +from django.conf import settings +from .forms import ContactForm + +logger = logging.getLogger(__name__) + + +class HomeView(TemplateView): + template_name = 'core/home.html' + + +class ContactAjaxView(View): + """Reçoit le formulaire en AJAX, répond en JSON — pas de rechargement.""" + + def post(self, request, *args, **kwargs): + form = ContactForm(request.POST) + if form.is_valid(): + contact = form.save() + subject = f"Nouvelle demande de contact — {contact.first_name} {contact.last_name}" + body = ( + f"Nom : {contact.last_name}\n" + f"Prénom : {contact.first_name}\n" + f"Email : {contact.email}\n" + f"Téléphone : {contact.phone or '—'}\n\n" + f"Demande :\n{contact.message}" + ) + try: + send_mail( + subject, + body, + settings.DEFAULT_FROM_EMAIL, + [settings.CONTACT_NOTIFY_EMAIL], + fail_silently=False, + ) + logger.info("Mail contact #%s envoyé → %s", contact.pk, settings.CONTACT_NOTIFY_EMAIL) + except Exception as e: + logger.error("Échec mail contact #%s : %s — %s", contact.pk, type(e).__name__, e) + return JsonResponse({'ok': True}) + else: + errors = {f: e.get_json_data() for f, e in form.errors.items()} + return JsonResponse({'ok': False, 'errors': errors}, status=400) + + +class AboutView(TemplateView): + template_name = 'core/about.html' + + +class KiriqView(TemplateView): + template_name = 'core/products/kiriq.html' + + +class MonitorView(TemplateView): + template_name = 'core/products/monitor.html' + + +class JoolidView(TemplateView): + template_name = 'core/products/joolid.html' + + +class PrivacyView(TemplateView): + template_name = 'core/privacy.html' + + +class RobotsTxtView(TemplateView): + template_name = 'robots.txt' + content_type = 'text/plain' + + +class SitemapXmlView(TemplateView): + template_name = 'sitemap.xml' + content_type = 'application/xml' diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..400e9e3 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..f8078ac --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,83 @@ +from pathlib import Path +import environ + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +env = environ.Env() +environ.Env.read_env(BASE_DIR / '.env') + +SECRET_KEY = env('SECRET_KEY') +ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=['localhost', '127.0.0.1']) + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # Local + 'apps.core.apps.CoreConfig', + 'apps.careers.apps.CareersConfig', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'apps.core.context_processors.site_context', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +LANGUAGE_CODE = 'fr-fr' +TIME_ZONE = 'Africa/Abidjan' +USE_I18N = True +USE_TZ = True + +STATIC_URL = '/static/' +STATICFILES_DIRS = [BASE_DIR / 'static'] +STATIC_ROOT = BASE_DIR / 'staticfiles' + +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +CAREERS_ENABLED = env.bool('CAREERS_ENABLED', default=False) + +DEFAULT_FROM_EMAIL = env('DEFAULT_FROM_EMAIL', default='noreply@jool-int.com') +CAREERS_NOTIFY_EMAIL = env('CAREERS_NOTIFY_EMAIL', default='rh@jool-int.com') +CONTACT_NOTIFY_EMAIL = env('CONTACT_NOTIFY_EMAIL', default='contacts@jool-int.com') + +DATA_UPLOAD_MAX_MEMORY_SIZE = 10 * 1024 * 1024 +FILE_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 diff --git a/config/settings/dev.py b/config/settings/dev.py new file mode 100644 index 0000000..256a205 --- /dev/null +++ b/config/settings/dev.py @@ -0,0 +1,29 @@ +from .base import * + +DEBUG = True + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +# Email SMTP réel (variables lues depuis .env) +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = env('EMAIL_HOST', default='smtp.dreamhost.com') +EMAIL_PORT = env.int('EMAIL_PORT', default=465) +EMAIL_USE_SSL = True +EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='') + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': {'class': 'logging.StreamHandler'}, + }, + 'loggers': { + 'apps.core': {'handlers': ['console'], 'level': 'INFO', 'propagate': False}, + }, +} diff --git a/config/settings/prod.py b/config/settings/prod.py new file mode 100644 index 0000000..d9ae18b --- /dev/null +++ b/config/settings/prod.py @@ -0,0 +1,61 @@ +from .base import * + +DEBUG = False + +DATABASES = { + 'default': env.db('DATABASE_URL', default='sqlite:///tmp/build.db') +} + +# WhiteNoise pour servir les fichiers statiques en fallback +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', +] + [m for m in MIDDLEWARE if m != 'django.middleware.security.SecurityMiddleware'] + +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = env('EMAIL_HOST', default='smtp.dreamhost.com') +EMAIL_PORT = env.int('EMAIL_PORT', default=465) +EMAIL_USE_SSL = True +EMAIL_HOST_USER = env('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD', default='') + +# ── HTTPS / cookies ─────────────────────────────────────── +SECURE_SSL_REDIRECT = True +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # derrière nginx + +SESSION_COOKIE_SECURE = True +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = 'Lax' + +CSRF_COOKIE_SECURE = True +CSRF_COOKIE_HTTPONLY = True +CSRF_COOKIE_SAMESITE = 'Lax' + +# ── HSTS ────────────────────────────────────────────────── +# Commencer à 3600, passer à 31536000 après validation SSL +SECURE_HSTS_SECONDS = 3600 +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = False # passer à True avec HSTS 1 an + +# ── Headers de sécurité Django ───────────────────────────── +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' +X_FRAME_OPTIONS = 'DENY' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': {'class': 'logging.StreamHandler'}, + }, + 'root': { + 'handlers': ['console'], + 'level': 'WARNING', + }, + 'loggers': { + 'django': {'handlers': ['console'], 'level': 'WARNING', 'propagate': False}, + 'apps.core': {'handlers': ['console'], 'level': 'INFO', 'propagate': False}, + }, +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..d36476a --- /dev/null +++ b/config/urls.py @@ -0,0 +1,32 @@ +""" +URL configuration for config project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static + +urlpatterns = [ + path('admin/', admin.site.urls), + path('', include('apps.core.urls', namespace='core')), +] + +if settings.CAREERS_ENABLED: + urlpatterns += [ + path('carrieres/', include('apps.careers.urls', namespace='careers')), + ] + +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..a479d2b --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.dev') + +application = get_wsgi_application() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..01c9c84 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,69 @@ +services: + + # ── Base de données PostgreSQL ────────────────────────── + db: + image: postgres:16-alpine + restart: always + volumes: + - postgres_data:/var/lib/postgresql/data + env_file: .env.prod + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + # ── Application Django (Gunicorn) ─────────────────────── + web: + build: . + restart: always + env_file: .env.prod + volumes: + - static_volume:/app/staticfiles + - media_volume:/app/media + depends_on: + db: + condition: service_healthy + command: > + sh -c "python manage.py migrate --noinput && + gunicorn config.wsgi:application + --bind 0.0.0.0:8000 + --workers 3 + --timeout 60 + --access-logfile - + --error-logfile -" + + # ── Nginx (reverse proxy + static files) ─────────────── + nginx: + image: nginx:alpine + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - static_volume:/app/staticfiles:ro + - media_volume:/app/media:ro + - certbot_www:/var/www/certbot:ro + - certbot_certs:/etc/letsencrypt:ro + depends_on: + - web + + # ── Certbot (SSL Let's Encrypt) ───────────────────────── + certbot: + image: certbot/certbot + volumes: + - certbot_www:/var/www/certbot + - certbot_certs:/etc/letsencrypt + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + +volumes: + postgres_data: + static_volume: + media_volume: + certbot_www: + certbot_certs: diff --git a/export_pages.py b/export_pages.py new file mode 100644 index 0000000..7c8fc5f --- /dev/null +++ b/export_pages.py @@ -0,0 +1,142 @@ +""" +Exporte chaque page Django en HTML autonome (CSS + JS inline). +Usage : python3 export_pages.py +""" +import os, re, base64, mimetypes +from pathlib import Path +import requests +from bs4 import BeautifulSoup +from urllib.parse import urljoin, urlparse + +BASE_URL = 'http://127.0.0.1:8000' +OUT_DIR = Path(__file__).parent / 'figma_export' +OUT_DIR.mkdir(exist_ok=True) + +PAGES = [ + ('home', '/'), + ('a-propos', '/a-propos/'), + ('kiriq', '/produits/kiriq/'), + ('monitor', '/produits/monitor/'), + ('joolid', '/produits/joolid/'), + ('carrieres','/carrieres/'), +] + +session = requests.Session() + +def fetch(url): + r = session.get(urljoin(BASE_URL, url), timeout=10) + r.raise_for_status() + return r + +def to_data_uri(url_or_path): + """Convertit une URL de ressource en data URI base64.""" + try: + r = fetch(url_or_path) + mime = r.headers.get('Content-Type', 'application/octet-stream').split(';')[0] + b64 = base64.b64encode(r.content).decode() + return f"data:{mime};base64,{b64}" + except Exception as e: + print(f" ⚠️ Impossible de charger {url_or_path}: {e}") + return url_or_path + +def inline_css(soup, base_url): + """Remplace les par des +{% endblock %} + +{% block content %} + + +
+
À propos de Jool International
+

L'intelligence agricole
au service de l'Afrique.

+

Jool International développe des solutions technologiques pour moderniser l'agriculture africaine. Du satellite au terrain, nous donnons aux industriels, coopératives et institutions les outils pour piloter leurs exploitations avec précision.

+
+ + +
+
+
+ Notre mission +

Transformer l'agriculture
africaine par la donnée.

+

En Afrique subsaharienne, 60% de la population active travaille dans l'agriculture, mais moins de 5% des exploitations ont accès à des outils numériques de gestion. Jool International comble ce fossé.

+

Nous construisons des outils adaptés aux réalités du terrain africain : connectivité limitée, diversité des cultures, multiplicité des acteurs et besoins de traçabilité pour les marchés internationaux.

+
+
+
+ visibility +
+

Transparence

+

Des données vérifiables et auditables à chaque étape de la chaîne de valeur agricole.

+
+
+
+ bolt +
+

Impact terrain

+

Nos solutions sont conçues pour être utilisées par des agents de terrain, pas seulement des ingénieurs.

+
+
+
+ public +
+

Scalabilité africaine

+

De 100 à 100 000 hectares, nos outils s'adaptent à toutes les tailles d'exploitation.

+
+
+
+
+
+ + +
+
+
+280 000
+
Producteurs digitalisés
+
+
+
+100 000 ha
+
Superficie suivie
+
+
+
3
+
Cultures couvertes
+
+
+
89%
+
Précision IA
+
+
+ + +
+
+

Nos solutions

+
+
+
satellite_alt
+
KIRIQ AI
+

Surveillance satellitaire et IA pour détecter les stress végétatifs, prioriser les interventions et piloter vos plantations depuis le bureau.

+ Découvrir KIRIQ AI east +
+
+
flight
+
Jool Monitor
+

Cartographie drone haute résolution, comptage automatique des plants et audit de conformité pour une vision ultra-précise du terrain.

+ Découvrir Monitor east +
+
+
badge
+
Jool ID
+

Digitalisation et centralisation des producteurs, parcelles et programmes agricoles pour une gestion fiable et traçable à grande échelle.

+ Découvrir Jool ID east +
+
+
+
+ + + + +
+
+

Basés en Côte d'Ivoire,
actifs en Afrique.

+

Jool International opère dans partout en Afrique de l'Ouest, là où les enjeux agricoles sont les plus importants.

+
location_on Abidjan, Côte d'Ivoire (siège)
+
email info@jool-int.com
+
phone +225 07 99 899 836
+
+
+ +
+
+ + +
+ Travaillons ensemble +

Prêt à transformer
votre exploitation ?

+

Discutons de vos besoins et voyons comment Jool peut vous aider.

+
+ + east Demander une démo + + {% if careers_enabled %} + + Rejoindre l'équipe + + {% endif %} +
+
+ +{% endblock %} diff --git a/templates/core/home.html b/templates/core/home.html new file mode 100644 index 0000000..c56f859 --- /dev/null +++ b/templates/core/home.html @@ -0,0 +1,84 @@ +{% extends "base.html" %} + +{% block title %}Jool International — Solutions AgriTech pour l'Afrique{% endblock %} +{% block title_plain %}Jool International — Solutions AgriTech pour l'Afrique{% endblock %} + +{% block meta_description %}Jool International propose des solutions AgriTech innovantes pour l'agriculture africaine : KIRIQ AI (diagnostic satellitaire), Jool Monitor (cartographie drone) et Jool ID (digitalisation producteurs). Optimisez vos exploitations agricoles en Côte d'Ivoire et en Afrique.{% endblock %} + +{% block og_title %}Jool International — Solutions AgriTech pour l'Afrique{% endblock %} +{% block og_description %}KIRIQ AI, Jool Monitor et Jool ID : les outils de précision pour piloter vos exploitations agricoles en Afrique.{% endblock %} +{% block twitter_title %}Jool International — Solutions AgriTech pour l'Afrique{% endblock %} +{% block twitter_description %}KIRIQ AI, Jool Monitor et Jool ID : les outils de précision pour piloter vos exploitations agricoles en Afrique.{% endblock %} + +{% block schema_org %} + +{% endblock %} + +{% block content %} + {% include "core/partials/_hero.html" %} + {% include "core/partials/_trust_strip.html" %} + {% include "core/partials/_section_kiriq.html" %} + {% include "core/partials/_section_monitor.html" %} + {% include "core/partials/_section_joolid.html" %} + {% include "core/partials/_stats.html" %} + {% include "core/partials/_trusted_by.html" %} + {% include "core/partials/_features.html" %} + {% include "core/partials/_faq.html" %} + {% include "core/partials/_cta_final.html" %} +{% endblock %} diff --git a/templates/core/partials/_cta_final.html b/templates/core/partials/_cta_final.html new file mode 100644 index 0000000..38de0c9 --- /dev/null +++ b/templates/core/partials/_cta_final.html @@ -0,0 +1,82 @@ + +
+
J
+
L
+
+

Transformez votre exploitation
agricole dès aujourd'hui.

+

Rejoignez les industriels et coopératives qui font confiance à Jool International.

+ +
+ {% csrf_token %} +
+ + + + +
+ + + +
+ +
+ check_circle + Votre demande a bien été envoyée ! Nous vous répondrons très prochainement. +
+
+
+ + diff --git a/templates/core/partials/_faq.html b/templates/core/partials/_faq.html new file mode 100644 index 0000000..774dd05 --- /dev/null +++ b/templates/core/partials/_faq.html @@ -0,0 +1,68 @@ + +
+
+

Questions ? Réponses.

+

Tout ce que vous devez savoir sur nos solutions AgriTech.

+
+ +
+
+ Faut-il une connexion internet sur le terrain pour utiliser vos solutions ? + add +
+
Non. Nos applications mobiles fonctionnent en mode hors-ligne. Les données se synchronisent + automatiquement dès qu'une connexion est disponible — idéal pour les zones rurales à connectivité limitée. +
+
+ +
+
+ Quelle est la différence entre KIRIQ AI et Jool Monitor ? + add +
+
KIRIQ AI analyse via satellite à grande échelle (milliers d'hectares) avec une fréquence + régulière. Jool Monitor utilise des drones pour une précision centimétrique sur des zones ciblées. Les deux + sont complémentaires : KIRIQ détecte les anomalies, Jool Monitor les confirme avec précision.
+
+ +
+
+ Quels types de cultures sont pris en charge ? + add +
+
Nos solutions sont optimisées pour le palmier à huile, l'hévéa, le cacao, le café et les + grandes cultures vivrières. Nos modèles IA peuvent être adaptés à d'autres cultures tropicales sur demande. +
+
+ +
+
+ Combien de temps pour recevoir les résultats après un vol drone ? + add +
+
Les rapports Jool Monitor sont livrés sous 24 à 48 heures après le vol. Pour KIRIQ AI, les + données satellitaires sont traitées et disponibles en temps quasi-réel selon la couverture nuageuse.
+
+ +
+
+ Jool ID est-il compatible avec nos systèmes existants ? + add +
+
Oui. Jool ID dispose d'une API ouverte permettant l'intégration avec vos ERP, bases de + données et outils de gestion existants. Notre équipe technique assure l'accompagnement à l'intégration. +
+
+ +
+
+ Où télécharger les applications mobiles ? + add +
+
Nos applications sont disponibles sur l'App Store (iOS) et Google Play (Android). + Contactez-nous pour obtenir votre accès entreprise et la configuration adaptée à votre exploitation.
+
+ +
+
+
diff --git a/templates/core/partials/_features.html b/templates/core/partials/_features.html new file mode 100644 index 0000000..a4f04ff --- /dev/null +++ b/templates/core/partials/_features.html @@ -0,0 +1,43 @@ + +
+
+

La solution rapide, fiable
et puissante pour l'agriculture.

+
+
+
speed
+

Résultats en 24h

+

Du vol drone au rapport final en moins de 24 heures. Des données exploitables dès le lendemain de + l'acquisition.

+
+
+
hub
+

Écosystème intégré

+

KIRIQ AI, Jool Monitor et Jool ID communiquent entre eux. Une vision unifiée de toutes vos exploitations. +

+
+
+
offline_bolt
+

Mode hors-ligne

+

Nos applications fonctionnent sans connexion. Idéal pour les zones rurales à connectivité limitée en + Afrique.

+
+
+
verified_user
+

Données certifiées

+

Traçabilité complète et données conformes aux exigences des certifications internationales (RSPO, ISO, + etc.).

+
+
+
insights
+

IA agronomique

+

Modèles d'IA entraînés spécifiquement sur les cultures tropicales africaines pour une précision maximale. +

+
+
+
support_agent
+

Support dédié

+

Une équipe d'experts agronomes et techniques disponible pour vous accompagner à chaque étape.

+
+
+
+
diff --git a/templates/core/partials/_footer.html b/templates/core/partials/_footer.html new file mode 100644 index 0000000..b56ed1d --- /dev/null +++ b/templates/core/partials/_footer.html @@ -0,0 +1,43 @@ + +