Initial commit
This commit is contained in:
0
apps/careers/__init__.py
Normal file
0
apps/careers/__init__.py
Normal file
72
apps/careers/admin.py
Normal file
72
apps/careers/admin.py
Normal 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
9
apps/careers/apps.py
Normal 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
50
apps/careers/forms.py
Normal 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
|
||||
66
apps/careers/migrations/0001_initial.py
Normal file
66
apps/careers/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/careers/migrations/__init__.py
Normal file
0
apps/careers/migrations/__init__.py
Normal file
103
apps/careers/models.py
Normal file
103
apps/careers/models.py
Normal 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
47
apps/careers/signals.py
Normal 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
3
apps/careers/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
apps/careers/urls.py
Normal file
11
apps/careers/urls.py
Normal 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
67
apps/careers/views.py
Normal 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
|
||||
Reference in New Issue
Block a user