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/__init__.py Normal file
View File

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

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

15
apps/core/admin.py Normal file
View File

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

6
apps/core/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.core'

View File

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

29
apps/core/forms.py Normal file
View File

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

View File

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

View File

19
apps/core/models.py Normal file
View File

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

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

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

17
apps/core/urls.py Normal file
View File

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

75
apps/core/views.py Normal file
View File

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