diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 345c317..988431b 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-313.pyc and b/NorahUniversity/__pycache__/settings.cpython-313.pyc differ diff --git a/db.sqlite3 b/db.sqlite3 new file mode 100644 index 0000000..5f93f5a Binary files /dev/null and b/db.sqlite3 differ diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index dfcdad2..7b08467 100644 Binary files a/recruitment/__pycache__/forms.cpython-313.pyc and b/recruitment/__pycache__/forms.cpython-313.pyc differ diff --git a/recruitment/__pycache__/linkedin_service.cpython-313.pyc b/recruitment/__pycache__/linkedin_service.cpython-313.pyc index 6cd5c87..5e09bbb 100644 Binary files a/recruitment/__pycache__/linkedin_service.cpython-313.pyc and b/recruitment/__pycache__/linkedin_service.cpython-313.pyc differ diff --git a/recruitment/__pycache__/models.cpython-313.pyc b/recruitment/__pycache__/models.cpython-313.pyc index c673a1c..ebf0204 100644 Binary files a/recruitment/__pycache__/models.cpython-313.pyc and b/recruitment/__pycache__/models.cpython-313.pyc differ diff --git a/recruitment/__pycache__/signals.cpython-313.pyc b/recruitment/__pycache__/signals.cpython-313.pyc index df8b0cd..71ee1e2 100644 Binary files a/recruitment/__pycache__/signals.cpython-313.pyc and b/recruitment/__pycache__/signals.cpython-313.pyc differ diff --git a/recruitment/__pycache__/urls.cpython-313.pyc b/recruitment/__pycache__/urls.cpython-313.pyc index 45ba5c0..3d7de95 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ diff --git a/recruitment/__pycache__/utils.cpython-313.pyc b/recruitment/__pycache__/utils.cpython-313.pyc index 4a8959a..0ce0d33 100644 Binary files a/recruitment/__pycache__/utils.cpython-313.pyc and b/recruitment/__pycache__/utils.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index dc13da4..b67b9f3 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 220e76c..5ccb8d2 100644 --- a/recruitment/forms.py +++ b/recruitment/forms.py @@ -4,7 +4,7 @@ from crispy_forms.helper import FormHelper from django.core.validators import URLValidator from django.utils.translation import gettext_lazy as _ from crispy_forms.layout import Layout, Submit, HTML, Div, Field -from .models import ZoomMeeting, Candidate,TrainingMaterial,JobPosting,FormTemplate +from .models import ZoomMeeting, Candidate,TrainingMaterial,JobPosting,FormTemplate,InterviewSchedule class CandidateForm(forms.ModelForm): class Meta: @@ -408,4 +408,48 @@ class FormTemplateForm(forms.ModelForm): Field('description', css_class='form-control'), Field('is_active', css_class='form-check-input'), Submit('submit', _('Create Template'), css_class='btn btn-primary mt-3') + ) + +class InterviewScheduleForm(forms.ModelForm): + candidates = forms.ModelMultipleChoiceField( + queryset=Candidate.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=True + ) + working_days = forms.MultipleChoiceField( + choices=[ + (0, 'Monday'), + (1, 'Tuesday'), + (2, 'Wednesday'), + (3, 'Thursday'), + (4, 'Friday'), + (5, 'Saturday'), + (6, 'Sunday'), + ], + widget=forms.CheckboxSelectMultiple, + required=True + ) + + class Meta: + model = InterviewSchedule + fields = [ + 'candidates', 'start_date', 'end_date', 'working_days', + 'start_time', 'end_time', 'break_start_time', 'break_end_time', + 'interview_duration', 'buffer_time' + ] + widgets = { + 'start_date': forms.DateInput(attrs={'type': 'date'}), + 'end_date': forms.DateInput(attrs={'type': 'date'}), + 'start_time': forms.TimeInput(attrs={'type': 'time'}), + 'end_time': forms.TimeInput(attrs={'type': 'time'}), + 'break_start_time': forms.TimeInput(attrs={'type': 'time'}), + 'break_end_time': forms.TimeInput(attrs={'type': 'time'}), + } + + def __init__(self, slug, *args, **kwargs): + super().__init__(*args, **kwargs) + # Filter candidates based on the selected job + self.fields['candidates'].queryset = Candidate.objects.filter( + job_slug=slug, + stage='Interview' ) \ No newline at end of file diff --git a/recruitment/migrations/0026_interviewschedule_scheduledinterview.py b/recruitment/migrations/0026_interviewschedule_scheduledinterview.py new file mode 100644 index 0000000..08541f3 --- /dev/null +++ b/recruitment/migrations/0026_interviewschedule_scheduledinterview.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.6 on 2025-10-07 14:12 + +import django.db.models.deletion +import django_extensions.db.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0025_formfield_max_files_formfield_multiple_files'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='InterviewSchedule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('start_date', models.DateField(verbose_name='Start Date')), + ('end_date', models.DateField(verbose_name='End Date')), + ('working_days', models.JSONField(verbose_name='Working Days')), + ('start_time', models.TimeField(verbose_name='Start Time')), + ('end_time', models.TimeField(verbose_name='End Time')), + ('break_start_time', models.TimeField(blank=True, null=True, verbose_name='Break Start Time')), + ('break_end_time', models.TimeField(blank=True, null=True, verbose_name='Break End Time')), + ('interview_duration', models.PositiveIntegerField(verbose_name='Interview Duration (minutes)')), + ('buffer_time', models.PositiveIntegerField(default=0, verbose_name='Buffer Time (minutes)')), + ('candidates', models.ManyToManyField(related_name='interview_schedules', to='recruitment.candidate')), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interview_schedules', to='recruitment.jobposting')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ScheduledInterview', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('slug', django_extensions.db.fields.RandomCharField(blank=True, editable=False, length=8, unique=True, verbose_name='Slug')), + ('interview_date', models.DateField(verbose_name='Interview Date')), + ('interview_time', models.TimeField(verbose_name='Interview Time')), + ('status', models.CharField(choices=[('scheduled', 'Scheduled'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], default='scheduled', max_length=20)), + ('candidate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.candidate')), + ('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_interviews', to='recruitment.jobposting')), + ('schedule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interviews', to='recruitment.interviewschedule')), + ('zoom_meeting', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='interview', to='recruitment.zoommeeting')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/recruitment/models.py b/recruitment/models.py index 2619a0b..88fa105 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -714,3 +714,46 @@ class HiringAgency(Base): verbose_name = _('Hiring Agency') verbose_name_plural = _('Hiring Agencies') ordering = ['name'] + + + +class InterviewSchedule(Base): + """Stores the scheduling criteria for interviews""" + job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name='interview_schedules') + candidates = models.ManyToManyField(Candidate, related_name='interview_schedules') + start_date = models.DateField(verbose_name=_('Start Date')) + end_date = models.DateField(verbose_name=_('End Date')) + working_days = models.JSONField(verbose_name=_('Working Days')) # Store days of week as [0,1,2,3,4] for Mon-Fri + start_time = models.TimeField(verbose_name=_('Start Time')) + end_time = models.TimeField(verbose_name=_('End Time')) + break_start_time = models.TimeField(verbose_name=_('Break Start Time'), null=True, blank=True) + break_end_time = models.TimeField(verbose_name=_('Break End Time'), null=True, blank=True) + interview_duration = models.PositiveIntegerField(verbose_name=_('Interview Duration (minutes)')) + buffer_time = models.PositiveIntegerField(verbose_name=_('Buffer Time (minutes)'), default=0) + created_by = models.ForeignKey(User, on_delete=models.CASCADE) + + def __str__(self): + return f"Interview Schedule for {self.job.title}" + +class ScheduledInterview(Base): + """Stores individual scheduled interviews""" + candidate = models.ForeignKey(Candidate, on_delete=models.CASCADE, related_name='scheduled_interviews') + job = models.ForeignKey(JobPosting, on_delete=models.CASCADE, related_name='scheduled_interviews') + zoom_meeting = models.OneToOneField(ZoomMeeting, on_delete=models.CASCADE, related_name='interview') + schedule = models.ForeignKey(InterviewSchedule, on_delete=models.CASCADE, related_name='interviews') + interview_date = models.DateField(verbose_name=_('Interview Date')) + interview_time = models.TimeField(verbose_name=_('Interview Time')) + status = models.CharField( + max_length=20, + choices=[ + ('scheduled', _('Scheduled')), + ('confirmed', _('Confirmed')), + ('cancelled', _('Cancelled')), + ('completed', _('Completed')), + ], + default='scheduled' + ) + + + def __str__(self): + return f"Interview with {self.candidate.name} for {self.job.title}" \ No newline at end of file diff --git a/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc b/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc index 76ea8d4..a5b4be1 100644 Binary files a/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc and b/recruitment/templatetags/__pycache__/form_filters.cpython-313.pyc differ diff --git a/recruitment/templatetags/form_filters.py b/recruitment/templatetags/form_filters.py index 6cbf01e..bf10384 100644 --- a/recruitment/templatetags/form_filters.py +++ b/recruitment/templatetags/form_filters.py @@ -22,31 +22,31 @@ def get_all_responses_flat(stage_responses): """ all_responses = [] if stage_responses: - print(stage_responses.get(9).get("responses")[0].value) for stage_id, responses in stage_responses.items(): - for response in responses: - # Check if response is an object or string - if hasattr(response, 'stage') and hasattr(response, 'field'): - stage_name = response.stage.name if hasattr(response.stage, 'name') else f"Stage {stage_id}" - field_label = response.field.label if hasattr(response.field, 'label') else "Unknown Field" - field_type = response.field.get_field_type_display() if hasattr(response.field, 'get_field_type_display') else "Unknown Type" - required = response.field.required if hasattr(response.field, 'required') else False - value = response.value if hasattr(response, 'value') else response - uploaded_file = response.uploaded_file if hasattr(response, 'uploaded_file') else None - else: - stage_name = f"Stage {stage_id}" - field_label = "Unknown Field" - field_type = "Text" - required = False - value = response - uploaded_file = None + if responses: # Check if responses list exists and is not empty + for response in responses: + # Check if response is an object or string + if hasattr(response, 'stage') and hasattr(response, 'field'): + stage_name = response.stage.name if hasattr(response.stage, 'name') else f"Stage {stage_id}" + field_label = response.field.label if hasattr(response.field, 'label') else "Unknown Field" + field_type = response.field.get_field_type_display() if hasattr(response.field, 'get_field_type_display') else "Unknown Type" + required = response.field.required if hasattr(response.field, 'required') else False + value = response.value if hasattr(response, 'value') else response + uploaded_file = response.uploaded_file if hasattr(response, 'uploaded_file') else None + else: + stage_name = f"Stage {stage_id}" + field_label = "Unknown Field" + field_type = "Text" + required = False + value = response + uploaded_file = None - all_responses.append({ - 'stage_name': stage_name, - 'field_label': field_label, - 'field_type': field_type, - 'required': required, - 'value': value, - 'uploaded_file': uploaded_file - }) + all_responses.append({ + 'stage_name': stage_name, + 'field_label': field_label, + 'field_type': field_type, + 'required': required, + 'value': value, + 'uploaded_file': uploaded_file + }) return all_responses diff --git a/recruitment/urls.py b/recruitment/urls.py index 5a1d25c..4f24bbb 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -19,6 +19,7 @@ urlpatterns = [ path('jobs/linkedin/login/', views.linkedin_login, name='linkedin_login'), path('jobs/linkedin/callback/', views.linkedin_callback, name='linkedin_callback'), + path('jobs//schedule-interviews/', views.schedule_interviews_view, name='schedule_interviews'), # Candidate URLs path('candidates/', views_frontend.CandidateListView.as_view(), name='candidate_list'), path('candidates/create/', views_frontend.CandidateCreateView.as_view(), name='candidate_create'), diff --git a/recruitment/utils.py b/recruitment/utils.py index 3524a1a..a4b3b06 100644 --- a/recruitment/utils.py +++ b/recruitment/utils.py @@ -4,7 +4,11 @@ # import requests from recruitment import models from django.conf import settings - +from datetime import datetime, timedelta, time, date +from django.utils import timezone +from .models import ScheduledInterview +from django.template.loader import render_to_string +from django.core.mail import send_mail # nlp = spacy.load("en_core_web_sm") # def extract_text_from_pdf(pdf_path): @@ -53,7 +57,7 @@ def extract_text_from_pdf(file_path): raise return text.strip() -def score_resume_with_openrouter(prompt): +def score_resume_with_openrouter(prompt): print("model call") response = requests.post( url="https://openrouter.ai/api/v1/chat/completions", @@ -63,7 +67,7 @@ def score_resume_with_openrouter(prompt): }, data=json.dumps({ "model": OPENROUTER_MODEL, - "messages": [{"role": "user", "content": prompt}], + "messages": [{"role": "user", "content": prompt}], }, ) ) @@ -75,15 +79,15 @@ def score_resume_with_openrouter(prompt): res = response.json() content = res["choices"][0]['message']['content'] try: - + content = content.replace("```json","").replace("```","") - + res = json.loads(content) - + except Exception as e: print(e) - # res = raw_output["choices"][0]["message"]["content"] + # res = raw_output["choices"][0]["message"]["content"] else: print("error response") return res @@ -381,4 +385,131 @@ def delete_zoom_meeting(meeting_id): return { "status": "error", "message": str(e) - } \ No newline at end of file + } + +def schedule_interviews(schedule): + """ + Schedule interviews for all candidates in the schedule based on the criteria. + Returns the number of interviews successfully scheduled. + """ + candidates = list(schedule.candidates.all()) + if not candidates: + return 0 + + # Calculate available time slots + available_slots = get_available_time_slots(schedule) + + if len(available_slots) < len(candidates): + raise ValueError(f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}") + + # Schedule interviews + scheduled_count = 0 + for i, candidate in enumerate(candidates): + slot = available_slots[i] + interview_datetime = datetime.combine(slot['date'], slot['time']) + + # Create Zoom meeting + meeting_topic = f"Interview for {schedule.job.title} - {candidate.name}" + meeting = create_zoom_meeting( + topic=meeting_topic, + start_time=interview_datetime, + duration=schedule.interview_duration, + timezone=timezone.get_current_timezone_name() + ) + + # Create scheduled interview record + scheduled_interview = ScheduledInterview.objects.create( + candidate=candidate, + job=schedule.job, + zoom_meeting=meeting, + schedule=schedule, + interview_date=slot['date'], + interview_time=slot['time'] + ) + + # Send email to candidate + send_interview_email(scheduled_interview) + + scheduled_count += 1 + + return scheduled_count + +def send_interview_email(scheduled_interview): + """ + Send an interview invitation email to the candidate. + """ + subject = f"Interview Invitation for {scheduled_interview.job.title}" + + context = { + 'candidate_name': scheduled_interview.candidate.name, + 'job_title': scheduled_interview.job.title, + 'company_name': scheduled_interview.job.company.name, + 'interview_date': scheduled_interview.interview_date, + 'interview_time': scheduled_interview.interview_time, + 'join_url': scheduled_interview.zoom_meeting.join_url, + 'meeting_id': scheduled_interview.zoom_meeting.meeting_id, + } + + # Render email templates + text_message = render_to_string('interviews/email/interview_invitation.txt', context) + html_message = render_to_string('interviews/email/interview_invitation.html', context) + + # Send email + send_mail( + subject=subject, + message=text_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[scheduled_interview.candidate.email], + html_message=html_message, + fail_silently=False, + ) + +def get_available_time_slots(schedule): + """ + Generate a list of available time slots based on the schedule criteria. + Returns a list of dictionaries with 'date' and 'time' keys. + """ + slots = [] + current_date = schedule.start_date + end_date = schedule.end_date + + # Convert working days to a set for quick lookup + working_days_set = set(schedule.working_days) + + # Parse times + start_time = schedule.start_time + end_time = schedule.end_time + break_start = schedule.break_start_time + break_end = schedule.break_end_time + + # Calculate slot duration (interview duration + buffer time) + slot_duration = timedelta(minutes=schedule.interview_duration + schedule.buffer_time) + + while current_date <= end_date: + # Check if current day is a working day + if current_date.weekday() in working_days_set: + # Generate slots for this day + current_time = start_time + + while current_time + slot_duration <= end_time: + # Check if slot is during break time + if break_start and break_end: + if current_time >= break_start and current_time < break_end: + current_time = break_end + continue + + # Add this slot to available slots + slots.append({ + 'date': current_date, + 'time': current_time + }) + + # Move to next slot + current_datetime = datetime.combine(current_date, current_time) + current_datetime += slot_duration + current_time = current_datetime.time() + + # Move to next day + current_date += timedelta(days=1) + + return slots \ No newline at end of file diff --git a/recruitment/views.py b/recruitment/views.py index f37d8da..59ac394 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -9,17 +9,17 @@ from django.db.models import Q from django.urls import reverse from django.conf import settings from django.utils import timezone -from .forms import ZoomMeetingForm,JobPostingForm,FormTemplateForm +from .forms import ZoomMeetingForm,JobPostingForm,FormTemplateForm,InterviewScheduleForm from rest_framework import viewsets from django.contrib import messages from django.core.paginator import Paginator from .linkedin_service import LinkedInService -from .models import FormTemplate, FormStage, FormField,FieldResponse,FormSubmission +from .models import FormTemplate, FormStage, FormField,FieldResponse,FormSubmission,InterviewSchedule from .models import ZoomMeeting, Candidate, JobPosting from .serializers import JobPostingSerializer, CandidateSerializer from django.shortcuts import get_object_or_404, render, redirect from django.views.generic import CreateView,UpdateView,DetailView,ListView -from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting +from .utils import create_zoom_meeting, delete_zoom_meeting, update_zoom_meeting,schedule_interviews,get_available_time_slots from django.views.decorators.csrf import ensure_csrf_cookie import logging @@ -843,3 +843,136 @@ def form_submission_details(request, form_id, submission_id): 'responses': responses, 'stage_responses': stage_responses }) + + + +def schedule_interviews_view(request, slug): + job = get_object_or_404(JobPosting, slug=slug) + + if request.method == 'POST': + form = InterviewScheduleForm(slug, request.POST) + + # Check if this is a confirmation request + if 'confirm_schedule' in request.POST: + # Get the schedule data from session + schedule_data = request.session.get('interview_schedule_data') + if not schedule_data: + messages.error(request, "Session expired. Please try again.") + return redirect('schedule_interviews', slug=slug) + + # Create the interview schedule + schedule = InterviewSchedule.objects.create( + job=job, + created_by=request.user, + **schedule_data + ) + + # Add candidates to the schedule + candidates = Candidate.objects.filter(id__in=schedule_data['candidate_ids']) + schedule.candidates.set(candidates) + + # Schedule the interviews + try: + scheduled_count = schedule_interviews(schedule) + messages.success( + request, + f"Successfully scheduled {scheduled_count} interviews." + ) + # Clear the session data + if 'interview_schedule_data' in request.session: + del request.session['interview_schedule_data'] + return redirect('job_detail', slug=slug) + except Exception as e: + messages.error( + request, + f"Error scheduling interviews: {str(e)}" + ) + return redirect('schedule_interviews', slug=slug) + + # This is the initial form submission + if form.is_valid(): + # Get the form data + candidates = form.cleaned_data['candidates'] + start_date = form.cleaned_data['start_date'] + end_date = form.cleaned_data['end_date'] + working_days = form.cleaned_data['working_days'] + start_time = form.cleaned_data['start_time'] + end_time = form.cleaned_data['end_time'] + break_start_time = form.cleaned_data['break_start_time'] + break_end_time = form.cleaned_data['break_end_time'] + interview_duration = form.cleaned_data['interview_duration'] + buffer_time = form.cleaned_data['buffer_time'] + + # Create a temporary schedule object (not saved to DB) + temp_schedule = InterviewSchedule( + job=job, + start_date=start_date, + end_date=end_date, + working_days=working_days, + start_time=start_time, + end_time=end_time, + break_start_time=break_start_time, + break_end_time=break_end_time, + interview_duration=interview_duration, + buffer_time=buffer_time + ) + + # Get available slots + available_slots = get_available_time_slots(temp_schedule) + + if len(available_slots) < len(candidates): + messages.error( + request, + f"Not enough available slots. Required: {len(candidates)}, Available: {len(available_slots)}" + ) + return render(request, 'interviews/schedule_interviews.html', { + 'form': form, + 'job': job + }) + + # Create a preview schedule + preview_schedule = [] + for i, candidate in enumerate(candidates): + slot = available_slots[i] + preview_schedule.append({ + 'candidate': candidate, + 'date': slot['date'], + 'time': slot['time'] + }) + + # Save the form data to session for later use + schedule_data = { + 'start_date': start_date.isoformat(), + 'end_date': end_date.isoformat(), + 'working_days': working_days, + 'start_time': start_time.isoformat(), + 'end_time': end_time.isoformat(), + 'break_start_time': break_start_time.isoformat() if break_start_time else None, + 'break_end_time': break_end_time.isoformat() if break_end_time else None, + 'interview_duration': interview_duration, + 'buffer_time': buffer_time, + 'candidate_ids': [c.id for c in candidates] + } + request.session['interview_schedule_data'] = schedule_data + + # Render the preview page + return render(request, 'interviews/preview_schedule.html', { + 'job': job, + 'schedule': preview_schedule, + 'start_date': start_date, + 'end_date': end_date, + 'working_days': working_days, + 'start_time': start_time, + 'end_time': end_time, + 'break_start_time': break_start_time, + 'break_end_time': break_end_time, + 'interview_duration': interview_duration, + 'buffer_time': buffer_time + }) + else: + form = InterviewScheduleForm(slug=slug) + + return render(request, 'interviews/schedule_interviews.html', { + 'form': form, + 'job': job + }) \ No newline at end of file diff --git a/templates/forms/form_builder.html b/templates/forms/form_builder.html index a3217a6..df6d614 100644 --- a/templates/forms/form_builder.html +++ b/templates/forms/form_builder.html @@ -1112,46 +1112,47 @@ state.draggedStageIndex = null; } // DOM Elements - const elements = { - stageNav: document.getElementById('stageNav'), - formStage: document.getElementById('formStage'), - emptyState: document.getElementById('emptyState'), - fieldEditor: document.getElementById('fieldEditor'), - currentStageTitle: document.getElementById('currentStageTitle'), - stageNameDisplay: document.getElementById('stageNameDisplay'), - stageRequiredIndicator: document.getElementById('stageRequiredIndicator'), - stagePredefinedBadge: document.getElementById('stagePredefinedBadge'), - addStageBtn: document.getElementById('addStageBtn'), - saveFormBtn: document.getElementById('saveFormBtn'), - renameStageBtn: document.getElementById('renameStageBtn'), - renameModal: document.getElementById('renameModal'), - stageName: document.getElementById('stageName'), - closeRenameModal: document.getElementById('closeRenameModal'), - cancelRenameBtn: document.getElementById('cancelRenameBtn'), - saveRenameBtn: document.getElementById('saveRenameBtn'), - fieldLabel: document.getElementById('fieldLabel'), - fieldPlaceholder: document.getElementById('fieldPlaceholder'), - placeholderGroup: document.getElementById('placeholderGroup'), - requiredField: document.getElementById('requiredField'), - optionsEditor: document.getElementById('optionsEditor'), - optionsList: document.getElementById('optionsList'), - addOptionBtn: document.getElementById('addOptionBtn'), - fileSettings: document.getElementById('fileSettings'), - fileTypes: document.getElementById('fileTypes'), - maxFileSize: document.getElementById('maxFileSize'), - closeEditorBtn: document.getElementById('closeEditorBtn'), - // Form settings elements - formTitle: document.getElementById('formTitle'), - formSettingsBtn: document.getElementById('formSettingsBtn'), - formSettingsModal: document.getElementById('formSettingsModal'), - formName: document.getElementById('formName'), - formDescription: document.getElementById('formDescription'), - formActive: document.getElementById('formActive'), - closeFormSettingsModal: document.getElementById('closeFormSettingsModal'), - cancelFormSettingsBtn: document.getElementById('cancelFormSettingsBtn'), - saveFormSettingsBtn: document.getElementById('saveFormSettingsBtn') - }; - +const elements = { + stageNav: document.getElementById('stageNav'), + formStage: document.getElementById('formStage'), + emptyState: document.getElementById('emptyState'), + fieldEditor: document.getElementById('fieldEditor'), + currentStageTitle: document.getElementById('currentStageTitle'), + stageNameDisplay: document.getElementById('stageNameDisplay'), + stageRequiredIndicator: document.getElementById('stageRequiredIndicator'), + stagePredefinedBadge: document.getElementById('stagePredefinedBadge'), + addStageBtn: document.getElementById('addStageBtn'), + saveFormBtn: document.getElementById('saveFormBtn'), + renameStageBtn: document.getElementById('renameStageBtn'), + renameModal: document.getElementById('renameModal'), + stageName: document.getElementById('stageName'), + closeRenameModal: document.getElementById('closeRenameModal'), + cancelRenameBtn: document.getElementById('cancelRenameBtn'), + saveRenameBtn: document.getElementById('saveRenameBtn'), + fieldLabel: document.getElementById('fieldLabel'), + fieldPlaceholder: document.getElementById('fieldPlaceholder'), + placeholderGroup: document.getElementById('placeholderGroup'), + requiredField: document.getElementById('requiredField'), + optionsEditor: document.getElementById('optionsEditor'), + optionsList: document.getElementById('optionsList'), + addOptionBtn: document.getElementById('addOptionBtn'), + fileSettings: document.getElementById('fileSettings'), + fileTypes: document.getElementById('fileTypes'), + maxFileSize: document.getElementById('maxFileSize'), + multipleFiles: document.getElementById('multipleFiles'), + maxFiles: document.getElementById('maxFiles'), + closeEditorBtn: document.getElementById('closeEditorBtn'), + // Form settings elements + formTitle: document.getElementById('formTitle'), + formSettingsBtn: document.getElementById('formSettingsBtn'), + formSettingsModal: document.getElementById('formSettingsModal'), + formName: document.getElementById('formName'), + formDescription: document.getElementById('formDescription'), + formActive: document.getElementById('formActive'), + closeFormSettingsModal: document.getElementById('closeFormSettingsModal'), + cancelFormSettingsBtn: document.getElementById('cancelFormSettingsBtn'), + saveFormSettingsBtn: document.getElementById('saveFormSettingsBtn') +}; // Utility Functions function getFieldIcon(type) { const icons = { @@ -1443,7 +1444,6 @@ fileUpload.appendChild(uploadedFile); }); } - fieldContent.appendChild(fileUpload); } else if (field.type === 'select') { const select = document.createElement('select'); @@ -1515,18 +1515,16 @@ }); }); - // Make draggable + // Make draggable for reordering fieldDiv.draggable = true; fieldDiv.addEventListener('dragstart', (e) => { state.draggedFieldIndex = parseInt(fieldDiv.dataset.fieldIndex); e.dataTransfer.setData('text/plain', 'reorder'); e.dataTransfer.effectAllowed = 'move'; }); - fieldDiv.addEventListener('dragover', (e) => { e.preventDefault(); }); - fieldDiv.addEventListener('drop', (e) => { e.preventDefault(); const targetIndex = parseInt(fieldDiv.dataset.fieldIndex); @@ -1536,21 +1534,30 @@ // Add file input event listener const fileInput = fieldDiv.querySelector('.file-input'); if (fileInput) { - fileInput.addEventListener('change', (e) => { + // Remove any existing listeners to prevent duplicates + const newFileInput = fileInput.cloneNode(true); + fileInput.parentNode.replaceChild(newFileInput, fileInput); + + newFileInput.addEventListener('change', (e) => { handleFileUpload(e, field); }); // Make the file upload area clickable const fileUploadArea = fieldDiv.querySelector('.file-upload-area'); if (fileUploadArea) { - fileUploadArea.addEventListener('click', () => { - fileInput.click(); + // Remove any existing listeners + const newFileUploadArea = fileUploadArea.cloneNode(true); + fileUploadArea.parentNode.replaceChild(newFileUploadArea, fileUploadArea); + + newFileUploadArea.addEventListener('click', () => { + newFileInput.click(); }); } } return fieldDiv; } + function handleFileUpload(event, field) { const files = Array.from(event.target.files); if (files.length === 0) return; @@ -1860,59 +1867,106 @@ function handleFileUpload(event, field) { // Initialize Event Listeners function initEventListeners() { - // Sidebar drag start - document.querySelectorAll('.field-item').forEach(item => { + // Wait for DOM to be fully loaded + setTimeout(() => { + // Sidebar drag start - ensure elements exist + const fieldItems = document.querySelectorAll('.field-item'); + fieldItems.forEach(item => { + if (!item.hasAttribute('data-drag-initialized')) { item.addEventListener('dragstart', (e) => { const type = item.dataset.type; const label = item.dataset.label; startDrag(e, type, label); }); - }); - // Form stage drop zone + item.setAttribute('data-drag-initialized', 'true'); + } + }); + + // Form stage drop zone + if (elements.formStage) { elements.formStage.addEventListener('drop', drop); elements.formStage.addEventListener('dragover', allowDrop); elements.formStage.addEventListener('dragenter', dragEnter); elements.formStage.addEventListener('dragleave', dragLeave); elements.formStage.addEventListener('click', clearSelection); - // Button events + } + + // Button events + if (elements.addStageBtn) { elements.addStageBtn.addEventListener('click', addStage); + } + if (elements.saveFormBtn) { elements.saveFormBtn.addEventListener('click', saveForm); + } + if (elements.renameStageBtn) { elements.renameStageBtn.addEventListener('click', () => { - openRenameModal(state.stages[state.currentStage]); + if (state.stages[state.currentStage]) { + openRenameModal(state.stages[state.currentStage]); + } }); - // Form settings button + } + + // Form settings button + if (elements.formSettingsBtn) { elements.formSettingsBtn.addEventListener('click', openFormSettingsModal); - // Modal events + } + + // Modal events - Stage Rename + if (elements.closeRenameModal) { elements.closeRenameModal.addEventListener('click', closeRenameModal); + } + if (elements.cancelRenameBtn) { elements.cancelRenameBtn.addEventListener('click', closeRenameModal); + } + if (elements.saveRenameBtn) { elements.saveRenameBtn.addEventListener('click', saveStageName); + } + if (elements.renameModal) { elements.renameModal.addEventListener('click', (e) => { if (e.target === elements.renameModal) { closeRenameModal(); } }); - // Form settings modal events + } + + // Form settings modal events + if (elements.closeFormSettingsModal) { elements.closeFormSettingsModal.addEventListener('click', closeFormSettingsModal); + } + if (elements.cancelFormSettingsBtn) { elements.cancelFormSettingsBtn.addEventListener('click', closeFormSettingsModal); + } + if (elements.saveFormSettingsBtn) { elements.saveFormSettingsBtn.addEventListener('click', saveFormSettings); + } + if (elements.formSettingsModal) { elements.formSettingsModal.addEventListener('click', (e) => { if (e.target === elements.formSettingsModal) { closeFormSettingsModal(); } }); - // Field editor events + } + + // Field editor events + if (elements.closeEditorBtn) { elements.closeEditorBtn.addEventListener('click', clearSelection); + } + if (elements.fieldLabel) { elements.fieldLabel.addEventListener('input', () => { if (state.selectedField) { state.selectedField.label = elements.fieldLabel.value; renderCurrentStage(); } }); + } + if (elements.fieldPlaceholder) { elements.fieldPlaceholder.addEventListener('input', () => { if (state.selectedField) { state.selectedField.placeholder = elements.fieldPlaceholder.value; } }); + } + if (elements.requiredField) { elements.requiredField.addEventListener('change', () => { if (state.selectedField) { state.selectedField.required = elements.requiredField.checked; @@ -1920,6 +1974,8 @@ function handleFileUpload(event, field) { renderCurrentStage(); } }); + } + if (elements.addOptionBtn) { elements.addOptionBtn.addEventListener('click', () => { if (state.selectedField && (state.selectedField.type === 'select' || @@ -1929,42 +1985,83 @@ function handleFileUpload(event, field) { renderOptionsEditor(state.selectedField); } }); + } + if (elements.fileTypes) { elements.fileTypes.addEventListener('input', () => { if (state.selectedField && state.selectedField.type === 'file') { state.selectedField.fileTypes = elements.fileTypes.value; } }); + } + if (elements.maxFileSize) { elements.maxFileSize.addEventListener('input', () => { if (state.selectedField && state.selectedField.type === 'file') { state.selectedField.maxFileSize = parseInt(elements.maxFileSize.value) || 5; } }); } + if (elements.multipleFiles) { + elements.multipleFiles.addEventListener('change', function() { + if (elements.maxFiles) { + elements.maxFiles.disabled = !this.checked; + if (!this.checked) { + elements.maxFiles.value = 1; + if (state.selectedField) { + state.selectedField.maxFiles = 1; + } + } + } + }); + } + }, 100); // Small delay to ensure DOM is ready +} + // Initialize Application function init() { - // Initialize form title - elements.formTitle.textContent = 'Loading...'; + // Initialize form title + if (elements.formTitle) { + elements.formTitle.textContent = 'Loading...'; + } // Hide the form stage initially to prevent flickering - elements.formStage.style.display = 'none'; - elements.emptyState.style.display = 'block'; - elements.emptyState.innerHTML = '

Loading form template...

'; + if (elements.formStage) { + elements.formStage.style.display = 'none'; + } + if (elements.emptyState) { + elements.emptyState.style.display = 'block'; + elements.emptyState.innerHTML = '

Loading form template...

'; + } + + // Initialize event listeners first + initEventListeners(); // Only render navigation if we have a template to load if (djangoConfig.loadUrl) { loadExistingTemplate(); } else { // For new templates, show empty state - elements.formTitle.textContent = 'New Form Template'; - elements.formStage.style.display = 'block'; + if (elements.formTitle) { + elements.formTitle.textContent = 'New Form Template'; + } + if (elements.formStage) { + elements.formStage.style.display = 'block'; + } + if (elements.emptyState) { + elements.emptyState.style.display = 'block'; + elements.emptyState.innerHTML = '

Drag form elements here to build your stage

'; + } renderStageNavigation(); renderCurrentStage(); } - } +} - // Start the application - document.addEventListener('DOMContentLoaded', init); +// Make sure the DOM is fully loaded before initializing +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} \ No newline at end of file diff --git a/templates/forms/form_submission_details.html b/templates/forms/form_submission_details.html index a2fd33f..0b38326 100644 --- a/templates/forms/form_submission_details.html +++ b/templates/forms/form_submission_details.html @@ -7,228 +7,57 @@
-
-
-

Submission Details

-

{{ form.name }}

-
- +
+

Submission Details

+ + Back +
- -
-
-
-
-
-
-

{{ submission.id }}

- Submission ID -
-
- -
-
-
-
-
-
-
-
-
-
-

{{ submission.submitted_at|date:"M d, Y" }}

- Submitted -
-
- -
-
-
-
-
-
-
-
-
-
-

{{ responses|length }}

- Fields Completed -
-
- -
-
-
-
-
-
- - -
-
-
Submission Information
-
+ +
-
- - - - - - - - - - {% if submission.submitted_by %} - - - - - {% endif %} -
Form:{{ form.name }}
Submitted:{{ submission.submitted_at|date:"F d, Y H:i" }}
Submitted By:{{ submission.submitted_by.get_full_name|default:submission.submitted_by.username }}
+
+ Submission ID: {{ submission.id }}
-
- {% if submission.applicant_name %} - - - - - - {% endif %} - {% if submission.applicant_email %} - - - - - {% endif %} -
Applicant Name:{{ submission.applicant_name }}
Email:{{ submission.applicant_email }}
+
+ Submitted: {{ submission.submitted_at|date:"M d, Y H:i" }} +
+
+ Form: {{ form.name }}
-
-
- - -
-
-
Submitted Responses
-
-
- {% for stage in stages %} -
-
-
-
- {{ stage.name }} -
-
-
- {% get_stage_responses stage_responses stage.id as stage_data %} - {% if stage_data %} -
- - - - - - - - - - - {% for response in stage_data %} - - - - - - - {% endfor %} - -
Field LabelField TypeResponse ValueFile
- {{ response.field.label }} - {% if response.field.required %} - * - {% endif %} - {{ response.field.get_field_type_display }} - {% if response.uploaded_file %} - File: {{ response.uploaded_file.name }} - {% elif response.value %} - {% if response.field.field_type == 'checkbox' and response.value|length > 0 %} -
    - {% for val in response.value %} -
  • {{ val }}
  • - {% endfor %} -
- {% elif response.field.field_type == 'radio' %} - {{ response.value }} - {% elif response.field.field_type == 'select' %} - {{ response.value }} - {% else %} -

{{ response.value|linebreaksbr }}

- {% endif %} - {% else %} - Not provided - {% endif %} -
- {% if response.uploaded_file %} - - Download - - {% endif %} -
-
- {% else %} -
- -

No responses submitted for this stage.

-
- {% endif %} -
+ {% if submission.applicant_name or submission.applicant_email %} +
+ {% if submission.applicant_name %} +
+ Applicant Name: {{ submission.applicant_name }}
+ {% endif %} + {% if submission.applicant_email %} +
+ Email: {{ submission.applicant_email }} +
+ {% endif %}
- {% if not forloop.last %} -
{% endif %} - {% empty %} -
- -

No stages found

-

This form doesn't have any stages defined.

-
- {% endfor %}
- -
-
-
All Responses (Raw Data)
-
+ +
+
Responses
{% get_all_responses_flat stage_responses as all_responses %} {% if all_responses %}
- - +
+ - - - @@ -236,35 +65,24 @@ {% for response in all_responses %} - - - @@ -285,138 +103,28 @@
Stage Field LabelField TypeRequired Response Value File
{{ response.stage_name }} - {{ response.field_label }} + {{ response.field_label }} {% if response.required %} - * - {% endif %} - {{ response.field_type }} - {% if response.required %} - Yes - {% else %} - No + * {% endif %} {% if response.uploaded_file %} - File: {{ response.uploaded_file.name }} + File: {{ response.uploaded_file.name }} {% elif response.value %} {% if response.field_type == 'checkbox' and response.value|length > 0 %} -
    +
    {% for val in response.value %} -
  • {{ val }}
  • + {{ val }} {% endfor %} -
- {% elif response.field_type == 'radio' %} + + {% elif response.field_type == 'radio' or response.field_type == 'select' %} {{ response.value }} - {% elif response.field_type == 'select' %} - {{ response.value }} {% else %}

{{ response.value|linebreaksbr }}

{% endif %} @@ -274,9 +92,9 @@
{% if response.uploaded_file %} - - Download - + + Download + {% endif %}
{% else %} -
- +

No responses found for this submission.

{% endif %}
- - - {% endblock %} {% block extra_css %} {% endblock %} - -{% block extra_js %} - -{% endblock %} diff --git a/templates/forms/form_templates_list.html b/templates/forms/form_templates_list.html index 0194a88..72d6484 100644 --- a/templates/forms/form_templates_list.html +++ b/templates/forms/form_templates_list.html @@ -269,6 +269,7 @@ {# Action area - visually separated with pt-2 border-top #}
+ {% trans "Preview" %} diff --git a/templates/interviews/email/interview_invitation.html b/templates/interviews/email/interview_invitation.html new file mode 100644 index 0000000..5766426 --- /dev/null +++ b/templates/interviews/email/interview_invitation.html @@ -0,0 +1,76 @@ + + + + + + Interview Invitation + + + +
+
+

Interview Invitation

+
+
+

Dear {{ candidate_name }},

+

We are pleased to invite you for an interview for the position of {{ job_title }} at {{ company_name }}.

+ +

Interview Details:

+
    +
  • Date: {{ interview_date|date:"l, F j, Y" }}
  • +
  • Time: {{ interview_time|time:"g:i A" }}
  • +
  • Platform: Zoom Video Conference
  • +
  • Meeting ID: {{ meeting_id }}
  • +
+ +

Please join the interview using the link below:

+ Join Interview + +

If you have any questions or need to reschedule, please contact us at your earliest convenience.

+ +

We look forward to speaking with you!

+ +

Best regards,
+ The Hiring Team
+ {{ company_name }}

+
+ +
+ + \ No newline at end of file diff --git a/templates/interviews/email/interview_invitation.txt b/templates/interviews/email/interview_invitation.txt new file mode 100644 index 0000000..e5e6d68 --- /dev/null +++ b/templates/interviews/email/interview_invitation.txt @@ -0,0 +1,23 @@ + +Interview Invitation + +Dear {{ candidate_name }}, + +We are pleased to invite you for an interview for the position of {{ job_title }} at {{ company_name }}. + +Interview Details: +- Date: {{ interview_date|date:"l, F j, Y" }} +- Time: {{ interview_time|time:"g:i A" }} +- Platform: Zoom Video Conference +- Meeting ID: {{ meeting_id }} + +Please join the interview using the link below: +{{ join_url }} + +If you have any questions or need to reschedule, please contact us at your earliest convenience. + +We look forward to speaking with you! + +Best regards, +The Hiring Team +{{ company_name }} \ No newline at end of file diff --git a/templates/interviews/preview_schedule.html b/templates/interviews/preview_schedule.html new file mode 100644 index 0000000..ffd8e18 --- /dev/null +++ b/templates/interviews/preview_schedule.html @@ -0,0 +1,125 @@ + +{% extends "base.html" %} +{% load static %} + +{% block content %} +
+

Interview Schedule Preview for {{ job.title }}

+ +
+
+
Schedule Details
+
+
+

Period: {{ start_date|date:"F j, Y" }} to {{ end_date|date:"F j, Y" }}

+

Working Days: + {% for day_id in working_days %} + {% if day_id == 0 %}Monday{% endif %} + {% if day_id == 1 %}Tuesday{% endif %} + {% if day_id == 2 %}Wednesday{% endif %} + {% if day_id == 3 %}Thursday{% endif %} + {% if day_id == 4 %}Friday{% endif %} + {% if day_id == 5 %}Saturday{% endif %} + {% if day_id == 6 %}Sunday{% endif %} + {% if not forloop.last %}, {% endif %} + {% endfor %} +

+

Working Hours: {{ start_time|time:"g:i A" }} to {{ end_time|time:"g:i A" }}

+
+
+ {% if break_start_time and break_end_time %} +

Break Time: {{ break_start_time|time:"g:i A" }} to {{ break_end_time|time:"g:i A" }}

+ {% endif %} +

Interview Duration: {{ interview_duration }} minutes

+

Buffer Time: {{ buffer_time }} minutes

+
+
+
+
+ +
+
+
Scheduled Interviews
+ + +
+
+
+ + +
+ + + + + + + + + + + {% for item in schedule %} + + + + + + + {% endfor %} + +
DateTimeCandidateEmail
{{ item.date|date:"F j, Y" }}{{ item.time|time:"g:i A" }}{{ item.candidate.name }}{{ item.candidate.email }}
+
+ +
+ {% csrf_token %} + + + Back to Edit + +
+
+
+
+ + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/interviews/schedule_interviews.html b/templates/interviews/schedule_interviews.html new file mode 100644 index 0000000..2588a74 --- /dev/null +++ b/templates/interviews/schedule_interviews.html @@ -0,0 +1,97 @@ + +{% extends "base.html" %} + +{% block content %} +
+

Schedule Interviews for {{ job.title }}

+ +
+
+
+ {% csrf_token %} + +
+
+
Select Candidates
+
+ {{ form.candidates }} +
+
+ +
+
Schedule Details
+ +
+ + {{ form.start_date }} +
+ +
+ + {{ form.end_date }} +
+ +
+ + {{ form.working_days }} +
+ +
+
+
+ + {{ form.start_time }} +
+
+ +
+
+ + {{ form.end_time }} +
+
+
+ +
+
+
+ + {{ form.break_start_time }} +
+
+ +
+
+ + {{ form.break_end_time }} +
+
+
+ +
+
+
+ + {{ form.interview_duration }} +
+
+ +
+
+ + {{ form.buffer_time }} +
+
+
+
+
+ +
+ + Cancel +
+
+
+
+
+{% endblock %} \ No newline at end of file