Initial commit

This commit is contained in:
ifaryd
2026-05-12 16:54:38 +00:00
commit 8638273475
92 changed files with 6861 additions and 0 deletions

0
apps/careers/__init__.py Normal file
View File

72
apps/careers/admin.py Normal file
View File

@@ -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)

9
apps/careers/apps.py Normal file
View File

@@ -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

50
apps/careers/forms.py Normal file
View File

@@ -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

View File

@@ -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')},
},
),
]

View File

103
apps/careers/models.py Normal file
View File

@@ -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}"

47
apps/careers/signals.py Normal file
View File

@@ -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,
)

3
apps/careers/tests.py Normal file
View File

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

11
apps/careers/urls.py Normal file
View File

@@ -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('<slug:slug>/', views.JobDetailView.as_view(), name='job_detail'),
path('<slug:slug>/postuler/', views.ApplyView.as_view(), name='apply'),
path('<slug:slug>/postuler/confirmation/', views.ApplySuccessView.as_view(), name='apply_success'),
]

67
apps/careers/views.py Normal file
View File

@@ -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