Merge branch 'main' of http://10.10.1.136:3000/marwan/kaauh_ats into frontend

This commit is contained in:
Faheed 2025-11-25 15:13:57 +03:00
commit 5b114b630e
21 changed files with 19986 additions and 287 deletions

6
.env
View File

@ -1,3 +1,3 @@
DB_NAME=haikal_db
DB_USER=faheed
DB_PASSWORD=Faheed@215
DB_NAME=norahuniversity
DB_USER=norahuniversity
DB_PASSWORD=norahuniversity

9882
django.po.bkp Normal file

File diff suppressed because it is too large Load Diff

9885
django2.po Normal file

File diff suppressed because it is too large Load Diff

View File

@ -25,6 +25,7 @@ class ERPIntegrationService:
Validate the incoming request from ERP system
Returns: (is_valid, error_message)
"""
# Check if source is active
if not self.source.is_active:
return False, "Source is not active"
@ -70,6 +71,7 @@ class ERPIntegrationService:
try:
# Map ERP fields to JobPosting fields
job_data = {
'internal_job_id': request_data.get('job_id', '').strip(),
'title': request_data.get('title', '').strip(),
'department': request_data.get('department', '').strip(),
'job_type': self.map_job_type(request_data.get('job_type', 'FULL_TIME')),

View File

@ -269,7 +269,7 @@ class SourceAdvancedForm(forms.ModelForm):
class PersonForm(forms.ModelForm):
class Meta:
model = Person
fields = ["first_name","middle_name", "last_name", "email", "phone","date_of_birth","nationality","address","gender"]
fields = ["first_name","middle_name", "last_name", "email", "phone","date_of_birth","nationality","gender","address"]
widgets = {
"first_name": forms.TextInput(attrs={'class': 'form-control'}),
"middle_name": forms.TextInput(attrs={'class': 'form-control'}),
@ -591,7 +591,6 @@ class JobPostingForm(forms.ModelForm):
attrs={
"class": "form-control",
"min": 1,
"placeholder": "Maximum number of applicants",
}
),
}
@ -834,9 +833,9 @@ class ProfileImageUploadForm(forms.ModelForm):
class StaffUserCreationForm(UserCreationForm):
email = forms.EmailField(required=True)
first_name = forms.CharField(max_length=30, required=True)
last_name = forms.CharField(max_length=150, required=True)
email = forms.EmailField(label=_("Email"), required=True)
first_name = forms.CharField(label=_("First Name"),max_length=30, required=True)
last_name = forms.CharField(label=_("Last Name"),max_length=150, required=True)
class Meta:
model = User
@ -1078,7 +1077,6 @@ class AgencyJobAssignmentForm(forms.ModelForm):
attrs={
"class": "form-control",
"min": 1,
"placeholder": "Maximum number of candidates",
}
),
"deadline_date": forms.DateTimeInput(
@ -1090,7 +1088,6 @@ class AgencyJobAssignmentForm(forms.ModelForm):
attrs={
"class": "form-control",
"rows": 3,
"placeholder": "Internal notes about this assignment",
}
),
}

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.6 on 2025-11-23 12:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recruitment', '0005_alter_interviewschedule_template_location'),
]
operations = [
migrations.AlterField(
model_name='customuser',
name='email',
field=models.EmailField(error_messages={'unique': 'A user with this email already exists.'}, max_length=254, unique=True),
),
]

View File

@ -47,6 +47,12 @@ class CustomUser(AbstractUser):
designation = models.CharField(
max_length=100, blank=True, null=True, verbose_name=_("Designation")
)
email = models.EmailField(
unique=True,
error_messages={
"unique": _("A user with this email already exists."),
},
)
class Meta:
verbose_name = _("User")
@ -487,7 +493,6 @@ class Person(Base):
unique=True,
db_index=True,
verbose_name=_("Email"),
help_text=_("Unique email address for the person"),
)
phone = models.CharField(
max_length=20, blank=True, null=True, verbose_name=_("Phone")

View File

@ -187,7 +187,6 @@ class PersonCreateView(CreateView):
template_name = "people/create_person.html"
form_class = PersonForm
success_url = reverse_lazy("person_list")
print("from agency")
def form_valid(self, form):
if "HX-Request" in self.request.headers:
instance = form.save()
@ -196,7 +195,6 @@ class PersonCreateView(CreateView):
slug = self.request.POST.get("agency")
if slug:
agency = HiringAgency.objects.get(slug=slug)
print(agency)
instance.agency = agency
instance.save()
return redirect("agency_portal_persons_list")
@ -867,7 +865,10 @@ def kaauh_career(request):
# job detail facing the candidate:
def application_detail(request, slug):
job = get_object_or_404(JobPosting, slug=slug)
return render(request, "applicant/application_detail.html", {"job": job})
already_applied = False
if request.user.is_authenticated:
already_applied = Application.objects.filter(job=job,person=request.user.person_profile).exists()
return render(request, "applicant/application_detail.html", {"job": job,"already_applied":already_applied})
@login_required
@ -1202,7 +1203,13 @@ def application_submit_form(request, template_slug):
"""Display the form as a step-by-step wizard"""
if not request.user.is_authenticated:
return redirect("candidate_signup",slug=template_slug)
template = get_object_or_404(FormTemplate, slug=template_slug, is_active=True)
if Application.objects.filter(job=template.job,person=request.user.person_profile).exists():
messages.error(request, _("You have already submitted an application for this job."))
return redirect("application_detail",slug=template.job.slug)
stage = template.stages.filter(name="Contact Information")

View File

@ -202,11 +202,14 @@ class ApplicationCreateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessa
job = get_object_or_404(models.JobPosting, slug=self.kwargs['slug'])
form.instance.job = job
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, f"{form.errors.as_text()}")
return super().form_invalid(form)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.method == 'GET':
context['person_form'] = forms.PersonForm()
# if self.request.method == 'GET':
context['person_form'] = forms.PersonForm()
return context
class ApplicationUpdateView(LoginRequiredMixin, StaffRequiredMixin, SuccessMessageMixin, UpdateView):

View File

@ -81,6 +81,17 @@ class ERPIntegrationView(View):
'message': 'Source not found'
}, status=404)
job_id = data.get('job_id')
if not job_id:
return JsonResponse({
'status': 'error',
'message': 'Job ID is required and must be unique'
})
if JobPosting.objects.filter(internal_job_id=job_id).exists():
return JsonResponse({
'status': 'error',
'message': 'Job with this ID already exists'
}, status=400)
# Create integration service
service = ERPIntegrationService(source)
@ -144,6 +155,7 @@ class ERPIntegrationView(View):
def _create_job(self, service: ERPIntegrationService, data: Dict[str, Any]) -> tuple[Dict[str, Any], str]:
"""Create a new job from ERP data"""
# Validate ERP data
# print(data)
is_valid, error_msg = service.validate_erp_data(data)
if not is_valid:
return None, error_msg
@ -152,7 +164,6 @@ class ERPIntegrationView(View):
job, error_msg = service.create_job_from_erp(data)
if error_msg:
return None, error_msg
# Prepare response data
response_data = {
'job_id': job.internal_job_id,

View File

@ -36,12 +36,22 @@
<p class="text-muted small mb-3">{% trans "Review the full job details below before submitting your application." %}</p>
{% if job.form_template %}
<a href="{% url 'application_submit_form' job.form_template.slug %}" class="btn btn-main-action btn-lg w-100 shadow-sm">
{% if user.is_authenticated and already_applied %}
<button class="btn btn-main-action btn-lg w-100" disabled>
<i class="fas fa-paper-plane me-2"></i> {% trans "You already applied for this position" %}
</button>
{% else %}
<a href="{% url 'application_submit_form' job.form_template.slug %}" class="btn btn-main-action btn-lg w-100">
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
</a>
{% endif %}
{% endif %}
{% comment %} <a href="{% url 'application_submit_form' job.form_template.slug %}" class="btn btn-main-action btn-lg w-100 shadow-sm">
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
</a>
{% elif not job.is_expired %}
<p class="text-danger fw-bold">{% trans "Application form is unavailable." %}</p>
{% endif %}
{% endif %} {% endcomment %}
</div>
</div>
</div>
@ -196,7 +206,6 @@
{% endwith %}
</div>
</div>
</article>
</div>
@ -205,11 +214,17 @@
{# 📱 MOBILE FIXED APPLY BAR (Replaced inline style with utility classes) #}
{% if job.form_template %}
<footer class="fixed-bottom d-lg-none bg-white border-top shadow-lg p-3">
<a href="{% url 'application_submit_form' job.form_template.slug %}" class="btn btn-main-action btn-lg w-100">
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
</a>
</footer>
<footer class="fixed-bottom d-lg-none bg-white border-top shadow-lg p-3">
{% if user.is_authenticated and already_applied %}
<button class="btn btn-main-action btn-lg w-100" disabled>
<i class="fas fa-paper-plane me-2"></i> {% trans "You already applied for this position" %}
</button>
{% else %}
<a href="{% url 'application_submit_form' job.form_template.slug %}" class="btn btn-main-action btn-lg w-100">
<i class="fas fa-paper-plane me-2"></i> {% trans "Apply for this Position" %}
</a>
{% endif %}
</footer>
{% endif %}

View File

@ -123,14 +123,16 @@
</li> {% endcomment %}
<li class="nav-item me-2">
{% if LANGUAGE_CODE == 'en' %}
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
<form action="{% url 'set_language' %}" method="post" class="d-inline">
{% csrf_token %}
<input name="next" type="hidden" value="{{ request.get_full_path }}">
<button name="language" value="ar" class="btn bg-primary-theme text-white" type="submit">
<span class="me-2">🇸🇦</span> العربية
</button>
</form>
{% elif LANGUAGE_CODE == 'ar' %}
<form action="{% url 'set_language' %}" method="post" class="d-inline">{% csrf_token %}
<form action="{% url 'set_language' %}" method="post" class="d-inline">
{% csrf_token %}
<input name="next" type="hidden" value="{{ request.get_full_path }}">
<button name="language" value="en" class="btn bg-primary-theme text-white" type="submit">
<span class="me-2">🇺🇸</span> English

View File

@ -1249,7 +1249,7 @@ const elements = {
if (result.success) {
state.templateId = result.template_slug;
window.location.href = "{% url 'form_templates_list' %}";
window.location.href = "{% url 'job_detail' template.job.slug %}";
} else {
alert('Error saving form template: ' + result.error);

View File

@ -361,7 +361,10 @@
{% trans "Manage the custom application forms associated with this job posting." %}
</p>
{% if not job.form_template %}
<a href="{% url 'form_builder' job.form_template.slug %}" class="btn btn-outline-secondary w-100">
<i class="fas fa-list-alt me-1"></i> {% trans "Manage Job Form" %}
</a>
{% comment %} {% if not job.form_template %}
<a href="{% url 'create_form_template' %}" class="btn btn-main-action">
<i class="fas fa-plus-circle me-1"></i> {% trans "Create New Form Template" %}
</a>
@ -377,7 +380,7 @@
<p>{% trans "This job status is not active, the form will appear once the job is made active"%}</p>
{% endif %}
{% endif %}
{% endif %} {% endcomment %}
</div>
</div>

View File

@ -276,7 +276,8 @@
<th scope="col" rowspan="2">{% trans "Source" %}</th>
<th scope="col" rowspan="2">{% trans "Max Apps" %}</th>
<th scope="col" rowspan="2">{% trans "Deadline" %}</th>
<th scope="col" rowspan="2">{% trans "Submission" %}</th>
<th scope="col" rowspan="2">{% trans "Assigned To" %}</th>
<th scope="col" rowspan="2"></th>
<th scope="col" colspan="6" class="candidate-management-header-title">
@ -306,11 +307,20 @@
<td>{{ job.get_source }}</td>
<td>{{ job.max_applications }}</td>
<td>{{ job.application_deadline|date:"d-m-Y" }}</td>
{% if job.assigned_to %}
<td>
<span class="badge bg-primary-theme">
{{ job.assigned_to.first_name|capfirst }} {{ job.assigned_to.last_name|capfirst }}
</span>
</td>
{% else %}
<td>{% trans "Unassigned" %}</td>
{% endif %}
<td>
{% if job.form_template %}
<div class="btn-group btn-group-sm" role="group">
<a href="{% url 'form_template_submissions_list' job.form_template.slug %}" class="btn btn-outline-secondary" title="{% trans 'All Application Submissions' %}">
<i class="fas fa-file-alt text-primary-theme"></i>
<i class="fas fa-file-alt text-primary-theme"></i>{% trans "Submissions" %}
</a>
</div>

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static i18n crispy_forms_tags %}
{% block title %}Create Person - {{ block.super }}{% endblock %}
{% block title %}Create Applicant - {{ block.super }}{% endblock %}
{% block customCSS %}
<style>
@ -184,9 +184,9 @@
<form method="post" enctype="multipart/form-data" id="person-form">
{% csrf_token %}
{{form|crispy}}
<!-- Profile Image Section -->
<div class="row mb-4">
{% comment %} <div class="row mb-4">
<div class="col-12">
<div class="profile-image-upload" onclick="document.getElementById('id_profile_image').click()">
<div id="image-preview-container">
@ -261,7 +261,7 @@
<div class="col-12">
{{ form.address }}
</div>
</div>
</div> {% endcomment %}
<!-- LinkedIn Profile Section -->
{% comment %} <div class="row mb-4">
@ -292,11 +292,8 @@
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</a>
<div class="d-flex gap-2">
<button type="reset" class="btn btn-outline-secondary">
<i class="fas fa-undo me-1"></i> {% trans "Reset" %}
</button>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {% trans "Create Person" %}
<i class="fas fa-save me-1"></i> {% trans "Create Applicant" %}
</button>
</div>
</div>
@ -308,141 +305,3 @@
</div>
</div>
{% endblock %}
{% block customJS %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Profile Image Preview
const profileImageInput = document.getElementById('id_profile_image');
const imagePreviewContainer = document.getElementById('image-preview-container');
profileImageInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file && file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = function(e) {
imagePreviewContainer.innerHTML = `
<img src="${e.target.result}" alt="Profile Preview" class="profile-image-preview">
<h5 class="text-muted mt-3">${file.name}</h5>
<p class="text-muted small">{% trans "Click to change photo" %}</p>
`;
};
reader.readAsDataURL(file);
}
});
// Form Validation
const form = document.getElementById('person-form');
form.addEventListener('submit', function(e) {
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.classList.add('loading');
submitBtn.disabled = true;
// Basic validation
const firstName = document.getElementById('id_first_name').value.trim();
const lastName = document.getElementById('id_last_name').value.trim();
const email = document.getElementById('id_email').value.trim();
if (!firstName || !lastName) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "First name and last name are required." %}');
return;
}
if (email && !isValidEmail(email)) {
e.preventDefault();
submitBtn.classList.remove('loading');
submitBtn.disabled = false;
alert('{% trans "Please enter a valid email address." %}');
return;
}
});
// Email validation helper
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// LinkedIn URL validation
const linkedinInput = document.getElementById('id_linkedin_profile');
linkedinInput.addEventListener('blur', function() {
const value = this.value.trim();
if (value && !isValidLinkedInURL(value)) {
this.classList.add('is-invalid');
if (!this.nextElementSibling || !this.nextElementSibling.classList.contains('invalid-feedback')) {
const feedback = document.createElement('div');
feedback.className = 'invalid-feedback';
feedback.textContent = '{% trans "Please enter a valid LinkedIn URL" %}';
this.parentNode.appendChild(feedback);
}
} else {
this.classList.remove('is-invalid');
const feedback = this.parentNode.querySelector('.invalid-feedback');
if (feedback) feedback.remove();
}
});
function isValidLinkedInURL(url) {
const linkedinRegex = /^https?:\/\/(www\.)?linkedin\.com\/.+/i;
return linkedinRegex.test(url);
}
// Drag and Drop functionality
const uploadArea = document.querySelector('.profile-image-upload');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
uploadArea.addEventListener(eventName, highlight, false);
});
['dragleave', 'drop'].forEach(eventName => {
uploadArea.addEventListener(eventName, unhighlight, false);
});
function highlight(e) {
uploadArea.style.borderColor = 'var(--kaauh-teal)';
uploadArea.style.backgroundColor = 'var(--kaauh-gray-light)';
}
function unhighlight(e) {
uploadArea.style.borderColor = 'var(--kaauh-border)';
uploadArea.style.backgroundColor = 'transparent';
}
uploadArea.addEventListener('drop', handleDrop, false);
function handleDrop(e) {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 0) {
profileImageInput.files = files;
const event = new Event('change', { bubbles: true });
profileImageInput.dispatchEvent(event);
}
}
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js" integrity="sha512-v2CJ7UaYy4JwqLDIrZUI/4hqeoQieOmAZNXBeQyjo21dadnwR+8ZaIJVT8EE2iyI61OV8e6M8PP2/4hpQINQ/g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" />
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
$('.select2').select2();
});
</script>
{% endblock %}

View File

@ -121,7 +121,7 @@
{% comment %} <li class="nav-item dropdown">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle text-white" href="#" role="button" data-bs-toggle="dropdown"
data-bs-offset="0, 8" aria-expanded="false" aria-label="{% trans 'Toggle language menu' %}">
<i class="fas fa-globe me-1"></i>

View File

@ -104,7 +104,7 @@
<div>
<h1 class="h3 mb-1" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-tasks me-2"></i>
{{ title }}
{% trans "Create New Assignment" %}
</h1>
<p class="text-muted mb-0">
{% trans "Assign a job to an external hiring agency" %}
@ -213,7 +213,7 @@
<i class="fas fa-times me-1"></i> {% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i> {{ button_text }}
<i class="fas fa-save me-1"></i> {% trans "Create Assignment" %}
</button>
</div>
</form>

View File

@ -279,7 +279,7 @@
<th scope="col" >{% trans "Major" %}</th>
<th scope="col" >{% trans "Stage" %}</th>
<th scope="col">{% trans "Hiring Source" %}</th>
<th scope="col" >{% trans "created At" %}</th>
<th scope="col" >{% trans "Created At" %}</th>
<th scope="col" class="text-end">{% trans "Actions" %}</th>
</tr>
</thead>

View File

@ -34,7 +34,7 @@
<div class="form-card">
<h2 id="form-title" class="h3 fw-bold mb-4 text-center">
<i class="fas fa-user-plus me-2 text-accent"></i>{% trans "Create Staff User" %}
<i class="fas fa-user-plus me-2 text-accent"></i>{% trans "Create User" %}
</h2>
{% if messages %}
@ -66,7 +66,7 @@
--bs-btn-active-border-color: #004d55;
--bs-btn-focus-shadow-rgb: 40, 167, 69;
--bs-btn-color: #ffffff;">
<i class="fas fa-save me-2"></i>{% trans "Create Staff User" %}
<i class="fas fa-save me-2"></i>{% trans "Create User" %}
</button>
</form>