diff --git a/NorahUniversity/__pycache__/settings.cpython-313.pyc b/NorahUniversity/__pycache__/settings.cpython-313.pyc index 8662d9d..345c317 100644 Binary files a/NorahUniversity/__pycache__/settings.cpython-313.pyc and b/NorahUniversity/__pycache__/settings.cpython-313.pyc differ diff --git a/NorahUniversity/settings.py b/NorahUniversity/settings.py index bc1e0ff..3a40e03 100644 --- a/NorahUniversity/settings.py +++ b/NorahUniversity/settings.py @@ -212,8 +212,6 @@ CELERY_RESULT_SERIALIZER = 'json' CELERY_TIMEZONE = 'UTC' - - LINKEDIN_CLIENT_ID = '867jwsiyem1504' LINKEDIN_CLIENT_SECRET = 'WPL_AP1.QNH5lYnfRSQpp0Qp.GO8Srw==' -LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/' +LINKEDIN_REDIRECT_URI = 'http://127.0.0.1:8000/jobs/linkedin/callback/' \ No newline at end of file diff --git a/recruitment/__pycache__/admin.cpython-313.pyc b/recruitment/__pycache__/admin.cpython-313.pyc index 149ef4d..87dcd28 100644 Binary files a/recruitment/__pycache__/admin.cpython-313.pyc and b/recruitment/__pycache__/admin.cpython-313.pyc differ diff --git a/recruitment/__pycache__/erp_integration_service.cpython-313.pyc b/recruitment/__pycache__/erp_integration_service.cpython-313.pyc index 54d652c..ad6e3bf 100644 Binary files a/recruitment/__pycache__/erp_integration_service.cpython-313.pyc and b/recruitment/__pycache__/erp_integration_service.cpython-313.pyc differ diff --git a/recruitment/__pycache__/forms.cpython-313.pyc b/recruitment/__pycache__/forms.cpython-313.pyc index 9c3cbe4..dfcdad2 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 e32921c..6cd5c87 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 0a696c7..c673a1c 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 6369bd0..df8b0cd 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 56c1900..45ba5c0 100644 Binary files a/recruitment/__pycache__/urls.cpython-313.pyc and b/recruitment/__pycache__/urls.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views.cpython-313.pyc b/recruitment/__pycache__/views.cpython-313.pyc index 0273c21..dc13da4 100644 Binary files a/recruitment/__pycache__/views.cpython-313.pyc and b/recruitment/__pycache__/views.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views_frontend.cpython-313.pyc b/recruitment/__pycache__/views_frontend.cpython-313.pyc index 5f6bd45..16dcc88 100644 Binary files a/recruitment/__pycache__/views_frontend.cpython-313.pyc and b/recruitment/__pycache__/views_frontend.cpython-313.pyc differ diff --git a/recruitment/__pycache__/views_integration.cpython-313.pyc b/recruitment/__pycache__/views_integration.cpython-313.pyc index a6a195d..6eb6e28 100644 Binary files a/recruitment/__pycache__/views_integration.cpython-313.pyc and b/recruitment/__pycache__/views_integration.cpython-313.pyc differ diff --git a/recruitment/forms.py b/recruitment/forms.py index 9faadaa..220e76c 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 +from .models import ZoomMeeting, Candidate,TrainingMaterial,JobPosting,FormTemplate class CandidateForm(forms.ModelForm): class Meta: @@ -366,3 +366,46 @@ class JobPostingForm(forms.ModelForm): # 'Job description is required for active jobs.') return cleaned_data + + +class FormTemplateForm(forms.ModelForm): + """Form for creating form templates""" + class Meta: + model = FormTemplate + fields = ['job','name', 'description', 'is_active'] + labels = { + 'job': _('Job'), + 'name': _('Template Name'), + 'description': _('Description'), + 'is_active': _('Active'), + } + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('Enter template name'), + 'required': True + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': _('Enter template description (optional)') + }), + 'is_active': forms.CheckboxInput(attrs={ + 'class': 'form-check-input' + }) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_method = 'post' + self.helper.form_class = 'form-horizontal' + self.helper.label_class = 'col-md-3' + self.helper.field_class = 'col-md-9' + self.helper.layout = Layout( + Field('job', css_class='form-control'), + Field('name', css_class='form-control'), + 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') + ) \ No newline at end of file diff --git a/recruitment/linkedin_service.py b/recruitment/linkedin_service.py index d1fb4f8..1e97f53 100644 --- a/recruitment/linkedin_service.py +++ b/recruitment/linkedin_service.py @@ -14,7 +14,7 @@ class LinkedInService: self.client_secret = settings.LINKEDIN_CLIENT_SECRET self.redirect_uri = settings.LINKEDIN_REDIRECT_URI self.access_token = None - + def get_auth_url(self): """Generate LinkedIn OAuth URL""" params = { @@ -25,7 +25,7 @@ class LinkedInService: 'state': 'university_ats_linkedin' } return f"https://www.linkedin.com/oauth/v2/authorization?{urlencode(params)}" - + def get_access_token(self, code): """Exchange authorization code for access token""" # This function exchanges LinkedInโ€™s temporary authorization code for a usable access token. @@ -37,7 +37,7 @@ class LinkedInService: 'client_id': self.client_id, 'client_secret': self.client_secret } - + try: response = requests.post(url, data=data, timeout=60) response.raise_for_status() @@ -53,15 +53,15 @@ class LinkedInService: except Exception as e: logger.error(f"Error getting access token: {e}") raise - + def get_user_profile(self): """Get user profile information""" if not self.access_token: raise Exception("No access token available") - + url = "https://api.linkedin.com/v2/userinfo" headers = {'Authorization': f'Bearer {self.access_token}'} - + try: response = requests.get(url, headers=headers, timeout=60) response.raise_for_status() # Ensure we raise an error for bad responses(4xx, 5xx) and does nothing for 2xx(success) @@ -72,8 +72,6 @@ class LinkedInService: - - def register_image_upload(self, person_urn): """Step 1: Register image upload with LinkedIn""" url = "https://api.linkedin.com/v2/assets?action=registerUpload" @@ -82,7 +80,7 @@ class LinkedInService: 'Content-Type': 'application/json', 'X-Restli-Protocol-Version': '2.0.0' } - + payload = { "registerUploadRequest": { "recipes": ["urn:li:digitalmediaRecipe:feedshare-image"], @@ -93,10 +91,10 @@ class LinkedInService: }] } } - + response = requests.post(url, headers=headers, json=payload, timeout=30) response.raise_for_status() - + data = response.json() return { 'upload_url': data['value']['uploadMechanism']['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest']['uploadUrl'], @@ -109,11 +107,11 @@ class LinkedInService: image_file.open() image_content = image_file.read() image_file.close() - + headers = { 'Authorization': f'Bearer {self.access_token}', } - + response = requests.post(upload_url, headers=headers, data=image_content, timeout=60) response.raise_for_status() return True @@ -121,59 +119,59 @@ class LinkedInService: """Create a job announcement post on LinkedIn (with image support)""" if not self.access_token: raise Exception("Not authenticated with LinkedIn") - + try: # Get user profile for person URN profile = self.get_user_profile() person_urn = profile.get('sub') - + if not person_urn: raise Exception("Could not retrieve LinkedIn user ID") - + # Check if job has an image try: image_upload = job_posting.files.first() has_image = image_upload and image_upload.linkedinpost_image except Exception: has_image = False - + if has_image: # === POST WITH IMAGE === try: # Step 1: Register image upload upload_info = self.register_image_upload(person_urn) - + # Step 2: Upload image self.upload_image_to_linkedin( - upload_info['upload_url'], + upload_info['upload_url'], image_upload.linkedinpost_image ) - + # Step 3: Create post with image return self.create_job_post_with_image( - job_posting, + job_posting, image_upload.linkedinpost_image, person_urn, upload_info['asset'] ) - + except Exception as e: logger.error(f"Image upload failed: {e}") # Fall back to text-only post if image upload fails has_image = False - + # === FALLBACK TO URL/ARTICLE POST === # Add unique timestamp to prevent duplicates from django.utils import timezone import random unique_suffix = f"\n\nPosted: {timezone.now().strftime('%b %d, %Y at %I:%M %p')} (ID: {random.randint(1000, 9999)})" - + message_parts = [f"๐Ÿš€ **We're Hiring: {job_posting.title}**"] if job_posting.department: message_parts.append(f"**Department:** {job_posting.department}") if job_posting.description: message_parts.append(f"\n{job_posting.description}") - + details = [] if job_posting.job_type: details.append(f"๐Ÿ’ผ {job_posting.get_job_type_display()}") @@ -183,22 +181,22 @@ class LinkedInService: details.append(f"๐Ÿ  {job_posting.get_workplace_type_display()}") if job_posting.salary_range: details.append(f"๐Ÿ’ฐ {job_posting.salary_range}") - + if details: message_parts.append("\n" + " | ".join(details)) - + if job_posting.application_url: message_parts.append(f"\n๐Ÿ”— **Apply now:** {job_posting.application_url}") - + hashtags = self.hashtags_list(job_posting.hash_tags) if job_posting.department: dept_hashtag = f"#{job_posting.department.replace(' ', '')}" hashtags.insert(0, dept_hashtag) - + message_parts.append("\n\n" + " ".join(hashtags)) message_parts.append(unique_suffix) message = "\n".join(message_parts) - + # ๐Ÿ”ฅ FIX URL - REMOVE TRAILING SPACES ๐Ÿ”ฅ url = "https://api.linkedin.com/v2/ugcPosts" headers = { @@ -206,7 +204,7 @@ class LinkedInService: 'Content-Type': 'application/json', 'X-Restli-Protocol-Version': '2.0.0' } - + payload = { "author": f"urn:li:person:{person_urn}", "lifecycleState": "PUBLISHED", @@ -226,29 +224,29 @@ class LinkedInService: "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC" } } - + response = requests.post(url, headers=headers, json=payload, timeout=60) response.raise_for_status() - + post_id = response.headers.get('x-restli-id', '') post_url = f"https://www.linkedin.com/feed/update/{quote(post_id)}/" if post_id else "" - + return { 'success': True, 'post_id': post_id, 'post_url': post_url, 'status_code': response.status_code } - + except Exception as e: logger.error(f"Error creating LinkedIn post: {e}") return { 'success': False, 'error': str(e), 'status_code': getattr(e.response, 'status_code', 500) if hasattr(e, 'response') else 500 - } + } + - def hashtags_list(self,hash_tags_str): """Convert comma-separated hashtags string to list""" @@ -257,8 +255,7 @@ class LinkedInService: tags = [tag.strip() for tag in hash_tags_str.split(',') if tag.strip()] if not tags: return ["#HigherEd", "#Hiring", "#FacultyJobs", "#UniversityJobs"] - + return tags - - - \ No newline at end of file + + diff --git a/recruitment/migrations/0025_formfield_max_files_formfield_multiple_files.py b/recruitment/migrations/0025_formfield_max_files_formfield_multiple_files.py new file mode 100644 index 0000000..3b07f37 --- /dev/null +++ b/recruitment/migrations/0025_formfield_max_files_formfield_multiple_files.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.6 on 2025-10-07 12:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('recruitment', '0024_fieldresponse_created_at_fieldresponse_slug_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='formfield', + name='max_files', + field=models.PositiveIntegerField(default=1, help_text='Maximum number of files allowed (when multiple_files is True)'), + ), + migrations.AddField( + model_name='formfield', + name='multiple_files', + field=models.BooleanField(default=False, help_text='Allow multiple files to be uploaded'), + ), + ] diff --git a/recruitment/migrations/__pycache__/0020_delete_job.cpython-313.pyc b/recruitment/migrations/__pycache__/0020_delete_job.cpython-313.pyc index a7e6738..b33ef20 100644 Binary files a/recruitment/migrations/__pycache__/0020_delete_job.cpython-313.pyc and b/recruitment/migrations/__pycache__/0020_delete_job.cpython-313.pyc differ diff --git a/recruitment/migrations/__pycache__/0021_source_api_key_source_api_secret_source_description_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0021_source_api_key_source_api_secret_source_description_and_more.cpython-313.pyc index 36981d7..53f32bd 100644 Binary files a/recruitment/migrations/__pycache__/0021_source_api_key_source_api_secret_source_description_and_more.cpython-313.pyc and b/recruitment/migrations/__pycache__/0021_source_api_key_source_api_secret_source_description_and_more.cpython-313.pyc differ diff --git a/recruitment/migrations/__pycache__/0022_alter_source_trusted_ips.cpython-313.pyc b/recruitment/migrations/__pycache__/0022_alter_source_trusted_ips.cpython-313.pyc index 7878be3..99727f1 100644 Binary files a/recruitment/migrations/__pycache__/0022_alter_source_trusted_ips.cpython-313.pyc and b/recruitment/migrations/__pycache__/0022_alter_source_trusted_ips.cpython-313.pyc differ diff --git a/recruitment/migrations/__pycache__/0023_alter_jobposting_application_url_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0023_alter_jobposting_application_url_and_more.cpython-313.pyc index 5049dfb..fbfeec0 100644 Binary files a/recruitment/migrations/__pycache__/0023_alter_jobposting_application_url_and_more.cpython-313.pyc and b/recruitment/migrations/__pycache__/0023_alter_jobposting_application_url_and_more.cpython-313.pyc differ diff --git a/recruitment/migrations/__pycache__/0024_fieldresponse_created_at_fieldresponse_slug_and_more.cpython-313.pyc b/recruitment/migrations/__pycache__/0024_fieldresponse_created_at_fieldresponse_slug_and_more.cpython-313.pyc index 714a9d7..f596782 100644 Binary files a/recruitment/migrations/__pycache__/0024_fieldresponse_created_at_fieldresponse_slug_and_more.cpython-313.pyc and b/recruitment/migrations/__pycache__/0024_fieldresponse_created_at_fieldresponse_slug_and_more.cpython-313.pyc differ diff --git a/recruitment/models.py b/recruitment/models.py index 973898d..2619a0b 100644 --- a/recruitment/models.py +++ b/recruitment/models.py @@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import RandomCharField from django.core.exceptions import ValidationError from django_countries.fields import CountryField +from django.urls import reverse class Base(models.Model): created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created at')) @@ -167,6 +168,12 @@ class JobPosting(Base): return self.application_deadline < timezone.now().date() return False + def publish(self): + self.status = 'PUBLISHED' + self.published_at = timezone.now() + self.application_url = reverse('form_wizard', kwargs={'slug': self.form_template.slug}) + self.save() + class Candidate(Base): class Stage(models.TextChoices): @@ -339,6 +346,7 @@ class FormTemplate(Base): return sum(stage.fields.count() for stage in self.stages.all()) + class FormStage(Base): """ Represents a stage/section within a form template @@ -402,15 +410,20 @@ class FormField(Base): default=5, help_text="Maximum file size in MB (default: 5MB)" ) + multiple_files = models.BooleanField( + default=False, + help_text="Allow multiple files to be uploaded" + ) + max_files = models.PositiveIntegerField( + default=1, + help_text="Maximum number of files allowed (when multiple_files is True)" + ) class Meta: ordering = ['order'] verbose_name = 'Form Field' verbose_name_plural = 'Form Fields' - def __str__(self): - return f"{self.stage.name} - {self.label}" - def clean(self): # Validate options for selection fields if self.field_type in ['select', 'radio', 'checkbox']: @@ -427,11 +440,18 @@ class FormField(Base): self.file_types = '.pdf,.doc,.docx' if self.max_file_size <= 0: raise ValidationError("Max file size must be greater than 0") + if self.multiple_files and self.max_files <= 0: + raise ValidationError("Max files must be greater than 0 when multiple files are allowed") + if not self.multiple_files: + self.max_files = 1 else: # Clear file settings for non-file fields self.file_types = '' self.max_file_size = 0 + self.multiple_files = False + self.max_files = 1 + # Validate order if self.order < 0: raise ValidationError("Order must be a positive integer") diff --git a/recruitment/signals.py b/recruitment/signals.py index 69f2b2d..912989d 100644 --- a/recruitment/signals.py +++ b/recruitment/signals.py @@ -1,6 +1,9 @@ -from django.db.models.signals import post_save -from django.dispatch import receiver from . import models +from django.urls import reverse +from django.db import transaction +from django.dispatch import receiver +from django.db.models.signals import post_save +from .models import FormField,FormStage,FormTemplate # @receiver(post_save, sender=models.Candidate) # def parse_resume(sender, instance, created, **kwargs): @@ -19,8 +22,6 @@ import os from .utils import extract_text_from_pdf,score_resume_with_openrouter import asyncio - - @receiver(post_save, sender=models.Candidate) def score_candidate_resume(sender, instance, created, **kwargs): # Skip if no resume or OpenRouter not configured @@ -105,9 +106,9 @@ def score_candidate_resume(sender, instance, created, **kwargs): Only output valid JSON. Do not include any other text. """ - - result1 = score_resume_with_openrouter(prompt) - + + result1 = score_resume_with_openrouter(prompt) + instance.parsed_summary = str(result) # Update candidate with scoring results @@ -115,8 +116,8 @@ def score_candidate_resume(sender, instance, created, **kwargs): instance.strengths = result1.get('strengths', '') instance.weaknesses = result1.get('weaknesses', '') instance.criteria_checklist = result1.get('criteria_checklist', {}) - - + + # Save only scoring-related fields to avoid recursion instance.save(update_fields=[ @@ -131,10 +132,291 @@ def score_candidate_resume(sender, instance, created, **kwargs): # instance.scoring_error = error_msg # instance.save(update_fields=['scoring_error']) logger.error(f"Failed to score resume for candidate {instance.id}: {e}") - + # @receiver(post_save,sender=models.Candidate) # def trigger_scoring(sender,intance,created,**kwargs): - \ No newline at end of file + +@receiver(post_save, sender=FormTemplate) +def create_default_stages(sender, instance, created, **kwargs): + """ + Create default resume stages when a new FormTemplate is created + """ + if created: # Only run for new templates, not updates + with transaction.atomic(): + # Stage 1: Contact Information + contact_stage = FormStage.objects.create( + template=instance, + name='Contact Information', + order=0, + is_predefined=True + ) + FormField.objects.create( + stage=contact_stage, + label='Full Name', + field_type='text', + required=True, + order=0, + is_predefined=True + ) + FormField.objects.create( + stage=contact_stage, + label='Email Address', + field_type='email', + required=True, + order=1, + is_predefined=True + ) + FormField.objects.create( + stage=contact_stage, + label='Phone Number', + field_type='phone', + required=True, + order=2, + is_predefined=True + ) + FormField.objects.create( + stage=contact_stage, + label='Address', + field_type='text', + required=False, + order=3, + is_predefined=True + ) + FormField.objects.create( + stage=contact_stage, + label='Resume Upload', + field_type='file', + required=True, + order=4, + is_predefined=True, + file_types='.pdf,.doc,.docx', + max_file_size=5 + ) + + # Stage 2: Resume Objective + objective_stage = FormStage.objects.create( + template=instance, + name='Resume Objective', + order=1, + is_predefined=True + ) + FormField.objects.create( + stage=objective_stage, + label='Career Objective', + field_type='textarea', + required=False, + order=0, + is_predefined=True + ) + + # Stage 3: Education + education_stage = FormStage.objects.create( + template=instance, + name='Education', + order=2, + is_predefined=True + ) + FormField.objects.create( + stage=education_stage, + label='Degree', + field_type='text', + required=True, + order=0, + is_predefined=True + ) + FormField.objects.create( + stage=education_stage, + label='Institution', + field_type='text', + required=True, + order=1, + is_predefined=True + ) + FormField.objects.create( + stage=education_stage, + label='Location', + field_type='text', + required=False, + order=2, + is_predefined=True + ) + FormField.objects.create( + stage=education_stage, + label='Graduation Date', + field_type='date', + required=False, + order=3, + is_predefined=True + ) + + # Stage 4: Experience + experience_stage = FormStage.objects.create( + template=instance, + name='Experience', + order=3, + is_predefined=True + ) + FormField.objects.create( + stage=experience_stage, + label='Position Title', + field_type='text', + required=True, + order=0, + is_predefined=True + ) + FormField.objects.create( + stage=experience_stage, + label='Company Name', + field_type='text', + required=True, + order=1, + is_predefined=True + ) + FormField.objects.create( + stage=experience_stage, + label='Location', + field_type='text', + required=False, + order=2, + is_predefined=True + ) + FormField.objects.create( + stage=experience_stage, + label='Start Date', + field_type='date', + required=True, + order=3, + is_predefined=True + ) + FormField.objects.create( + stage=experience_stage, + label='End Date', + field_type='date', + required=True, + order=4, + is_predefined=True + ) + FormField.objects.create( + stage=experience_stage, + label='Responsibilities & Achievements', + field_type='textarea', + required=False, + order=5, + is_predefined=True + ) + + # Stage 5: Skills + skills_stage = FormStage.objects.create( + template=instance, + name='Skills', + order=4, + is_predefined=True + ) + FormField.objects.create( + stage=skills_stage, + label='Technical Skills', + field_type='checkbox', + required=False, + order=0, + is_predefined=True, + options=['Programming Languages', 'Frameworks', 'Tools & Technologies'] + ) + + # Stage 6: Summary + summary_stage = FormStage.objects.create( + template=instance, + name='Summary', + order=5, + is_predefined=True + ) + FormField.objects.create( + stage=summary_stage, + label='Professional Summary', + field_type='textarea', + required=False, + order=0, + is_predefined=True + ) + + # Stage 7: Certifications + certifications_stage = FormStage.objects.create( + template=instance, + name='Certifications', + order=6, + is_predefined=True + ) + FormField.objects.create( + stage=certifications_stage, + label='Certification Name', + field_type='text', + required=False, + order=0, + is_predefined=True + ) + FormField.objects.create( + stage=certifications_stage, + label='Issuing Organization', + field_type='text', + required=False, + order=1, + is_predefined=True + ) + FormField.objects.create( + stage=certifications_stage, + label='Issue Date', + field_type='date', + required=False, + order=2, + is_predefined=True + ) + FormField.objects.create( + stage=certifications_stage, + label='Expiration Date', + field_type='date', + required=False, + order=3, + is_predefined=True + ) + + # Stage 8: Awards and Recognitions + awards_stage = FormStage.objects.create( + template=instance, + name='Awards and Recognitions', + order=7, + is_predefined=True + ) + FormField.objects.create( + stage=awards_stage, + label='Award Name', + field_type='text', + required=False, + order=0, + is_predefined=True + ) + FormField.objects.create( + stage=awards_stage, + label='Issuing Organization', + field_type='text', + required=False, + order=1, + is_predefined=True + ) + FormField.objects.create( + stage=awards_stage, + label='Date Received', + field_type='date', + required=False, + order=2, + is_predefined=True + ) + FormField.objects.create( + stage=awards_stage, + label='Description', + field_type='textarea', + required=False, + order=3, + is_predefined=True + ) \ No newline at end of file diff --git a/recruitment/urls.py b/recruitment/urls.py index 4d7514d..a1c2404 100644 --- a/recruitment/urls.py +++ b/recruitment/urls.py @@ -57,6 +57,7 @@ urlpatterns = [ path('forms/builder/', views.form_builder, name='form_builder'), path('forms/builder//', views.form_builder, name='form_builder'), path('forms/', views.form_templates_list, name='form_templates_list'), + path('forms/create-template/', views.create_form_template, name='create_form_template'), path('forms/form//', views.form_wizard_view, name='form_wizard'), path('forms/form//submit/', views.submit_form, name='submit_form'), diff --git a/recruitment/views.py b/recruitment/views.py index 2f26c1b..b671f81 100644 --- a/recruitment/views.py +++ b/recruitment/views.py @@ -7,8 +7,9 @@ from datetime import datetime from django.views import View 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 +from .forms import ZoomMeetingForm,JobPostingForm,FormTemplateForm from rest_framework import viewsets from django.contrib import messages from django.core.paginator import Paginator @@ -20,8 +21,8 @@ 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 django.views.decorators.csrf import ensure_csrf_cookie - import logging + logger=logging.getLogger(__name__) @@ -321,6 +322,7 @@ def linkedin_callback(request): access_token=service.get_access_token(code) request.session['linkedin_access_token']=access_token request.session['linkedin_authenticated']=True + settings.LINKEDIN_IS_CONNECTED = True messages.success(request,'Successfully authenticated with LinkedIn!') except Exception as e: logger.error(f"LinkedIn authentication error: {e}") @@ -685,10 +687,11 @@ def load_form_template(request, template_id): 'id': template.id, 'name': template.name, 'description': template.description, + 'is_active': template.is_active, + 'job': template.job_id if template.job else None, 'stages': stages } }) - def form_templates_list(request): """List all form templates for the current user""" query = request.GET.get('q', '') @@ -703,13 +706,32 @@ def form_templates_list(request): paginator = Paginator(templates, 10) # Show 10 templates per page page_number = request.GET.get('page') page_obj = paginator.get_page(page_number) - + form = FormTemplateForm() + form.fields['job'].queryset = JobPosting.objects.filter(form_template__isnull=True) context = { 'templates': page_obj, 'query': query, + 'form': form } return render(request, 'forms/form_templates_list.html', context) + +def create_form_template(request): + """Create a new form template""" + if request.method == 'POST': + form = FormTemplateForm(request.POST) + if form.is_valid(): + template = form.save(commit=False) + template.created_by = request.user + template.save() + + messages.success(request, f'Form template "{template.name}" created successfully!') + return redirect('form_builder', template_id=template.id) + else: + form = FormTemplateForm() + + return render(request, 'forms/create_form_template.html', {'form': form}) + @require_http_methods(["GET"]) def list_form_templates(request): """List all form templates for the current user""" diff --git a/templates/forms/create_form_template.html b/templates/forms/create_form_template.html new file mode 100644 index 0000000..03688e3 --- /dev/null +++ b/templates/forms/create_form_template.html @@ -0,0 +1,208 @@ +{% extends 'base.html' %} +{% load static i18n %} +{% load crispy_forms_tags %} + +{% block title %}Create Form Template - ATS{% endblock %} + +{% block customCSS %} + +{% endblock %} + +{% block content %} +
+
+

+ Create Form Template +

+ + Back to Templates + +
+ +
+
+
+
+

New Form Template

+
+
+
+ {% csrf_token %} + {{ form|crispy }} +
+ + Cancel + + +
+
+
+
+
+
+
+ +{% if messages %} + {% for message in messages %} + + {% endfor %} +{% endif %} +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/forms/form_builder.html b/templates/forms/form_builder.html index 842bc4e..a3217a6 100644 --- a/templates/forms/form_builder.html +++ b/templates/forms/form_builder.html @@ -12,21 +12,23 @@ --primary: #004a53; /* Deep Teal/Cyan for main actions */ --primary-light: #00b4d8; /* Brighter Aqua/Cyan */ --secondary: #005a78; /* Darker Teal for hover/accent */ + --success: #00cc99; /* Bright Greenish-Teal for success */ + --success: #005a78; /* Bright Greenish-Teal for success */ - + /* Neutral Colors (Kept for consistency) */ --light: #f4fcfc; /* Very light off-white (slightly blue tinted) */ --dark: #212529; /* Near black text */ --gray: #6c757d; /* Standard gray text */ --light-gray: #e0f0f4; /* Lighter background for hover/disabled */ --border: #c4d7e0; /* Lighter, softer border color */ - + /* Structural Variables (Kept exactly the same) */ --shadow: 0 4px 6px rgba(0, 0, 0, 0.1); --radius: 8px; --transition: all 0.3s ease; } - /* All other structural and component styles below remain the same, + /* All other structural and component styles below remain the same, but will automatically adopt the new colors defined above. */ * { margin: 0; @@ -670,18 +672,20 @@ - +
+

File Settings

+
+ + +
+
+ + +
+
+
+ + +
+ Enable this to allow uploading multiple files for this field. +
+
+ + + Only applicable when multiple files are allowed. +
+
@@ -1156,97 +1182,109 @@ // API Functions async function saveFormTemplate() { - const formData = { - name: state.formName, - description: state.formDescription, - is_active: state.formActive, - template_id: state.templateId, // Include template_id for updates - stages: state.stages.map(stage => ({ - name: stage.name, - predefined: stage.predefined, - fields: stage.fields.map(field => ({ - type: field.type, - label: field.label, - placeholder: field.placeholder || '', - required: field.required || false, - options: field.options || [], - fileTypes: field.fileTypes || '', - maxFileSize: field.maxFileSize || 5, - predefined: field.predefined - })) - })) - }; + const formData = { + name: state.formName, + description: state.formDescription, + is_active: state.formActive, + template_id: state.templateId, + stages: state.stages.map(stage => ({ + name: stage.name, + predefined: stage.predefined, + fields: stage.fields.map(field => ({ + type: field.type, + label: field.label, + placeholder: field.placeholder || '', + required: field.required || false, + options: field.options || [], + fileTypes: field.fileTypes || '', + maxFileSize: field.maxFileSize || 5, + predefined: field.predefined + })) + })) + }; - try { - const response = await fetch(djangoConfig.saveUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': djangoConfig.csrfToken, - 'X-Requested-With': 'XMLHttpRequest' - }, - body: JSON.stringify(formData) - }); + // If there's a job_id in the Django context, include it + if (djangoConfig.jobId) { + formData.job = djangoConfig.jobId; + } - const result = await response.json(); + try { + const response = await fetch(djangoConfig.saveUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': djangoConfig.csrfToken, + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify(formData) + }); - if (result.success) { - alert('Form template saved successfully! Template ID: ' + result.template_id); - // Update templateId for future saves (important for new templates) - state.templateId = result.template_id; - } else { - alert('Error saving form template: ' + result.error); - } - } catch (error) { - console.error('Error:', error); - alert('Error saving form template. Please try again.'); - } + const result = await response.json(); + + if (result.success) { + state.templateId = result.template_id; + window.location.href = "{% url 'form_templates_list' %}"; + + } else { + alert('Error saving form template: ' + result.error); } - + } catch (error) { + console.error('Error:', error); + alert('Error saving form template. Please try again.'); + } +} // Load existing template if editing async function loadExistingTemplate() { - if (djangoConfig.loadUrl) { - try { - const response = await fetch(djangoConfig.loadUrl); - const result = await response.json(); + if (djangoConfig.loadUrl) { + try { + const response = await fetch(djangoConfig.loadUrl); + const result = await response.json(); + if (result.success) { + const templateData = result.template; + // Set form settings + state.formName = templateData.name || 'Untitled Form'; + state.formDescription = templateData.description || ''; + state.formActive = templateData.is_active !== false; - if (result.success) { - const templateData = result.template; - // Set form settings - state.formName = templateData.name || 'Untitled Form'; - state.formDescription = templateData.description || ''; - state.formActive = templateData.is_active !== false; // Default to true if not set + // Update form title + elements.formTitle.textContent = state.formName; + elements.formName.value = state.formName; + elements.formDescription.value = state.formDescription; + elements.formActive.checked = state.formActive; - // Update form title - elements.formTitle.textContent = state.formName; - elements.formName.value = state.formName; - elements.formDescription.value = state.formDescription; - elements.formActive.checked = state.formActive; + // Set stages (this is where your actual stages come from) + state.stages = templateData.stages; + state.templateId = templateData.id; - // Set stages - state.stages = templateData.stages; - state.templateId = templateData.id; - // Update next IDs to avoid conflicts - let maxFieldId = 0; - let maxStageId = 0; - templateData.stages.forEach(stage => { - maxStageId = Math.max(maxStageId, stage.id); - stage.fields.forEach(field => { - maxFieldId = Math.max(maxFieldId, field.id); - }); - }); - state.nextFieldId = maxFieldId + 1; - state.nextStageId = maxStageId + 1; - state.currentStage = 0; - renderStageNavigation(); - renderCurrentStage(); - } - } catch (error) { - console.error('Error loading template:', error); - alert('Error loading template data.'); - } + // Update next IDs to avoid conflicts + let maxFieldId = 0; + let maxStageId = 0; + templateData.stages.forEach(stage => { + maxStageId = Math.max(maxStageId, stage.id); + stage.fields.forEach(field => { + maxFieldId = Math.max(maxFieldId, field.id); + }); + }); + state.nextFieldId = maxFieldId + 1; + state.nextStageId = maxStageId + 1; + state.currentStage = 0; + + // Now show the form content + elements.formStage.style.display = 'block'; + elements.emptyState.style.display = 'none'; + + renderStageNavigation(); + renderCurrentStage(); } + } catch (error) { + console.error('Error loading template:', error); + elements.formTitle.textContent = 'Error Loading Template'; + elements.emptyState.style.display = 'block'; + elements.emptyState.innerHTML = '

Error loading template data.

'; + elements.formStage.style.display = 'none'; } + } +} // DOM Rendering Functions (same as before) function renderStageNavigation() { @@ -1319,164 +1357,255 @@ } function createFieldElement(field, index) { - const fieldDiv = document.createElement('div'); - fieldDiv.className = `form-field ${state.selectedField && state.selectedField.id === field.id ? 'selected' : ''}`; - fieldDiv.dataset.fieldId = field.id; - fieldDiv.dataset.fieldIndex = index; - const fieldHeader = document.createElement('div'); - fieldHeader.className = 'field-header'; - fieldHeader.innerHTML = ` -
- - ${field.label || field.type.charAt(0).toUpperCase() + field.type.slice(1)} - ${field.required ? ' *' : ''} -
-
-
- -
- ${!field.predefined ? `
- -
` : ''} -
- `; - const fieldContent = document.createElement('div'); - fieldContent.className = 'field-content'; - fieldContent.innerHTML = ` - - `; - // Add field input based on type - if (field.type === 'text' || field.type === 'email' || field.type === 'phone' || field.type === 'date') { - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'field-input'; - input.placeholder = field.placeholder || 'Enter value'; - input.disabled = true; - fieldContent.appendChild(input); - } else if (field.type === 'textarea') { - const textarea = document.createElement('textarea'); - textarea.className = 'field-input'; - textarea.rows = 3; - textarea.placeholder = field.placeholder || 'Enter text'; - textarea.disabled = true; - fieldContent.appendChild(textarea); - } else if (field.type === 'file') { - const fileUpload = document.createElement('div'); - fileUpload.className = 'file-upload-area'; - fileUpload.innerHTML = ` -
- -
-
-

Drag & drop your resume here or click to browse

-
-
-

Supported formats: ${field.fileTypes || '.pdf, .doc, .docx'} (Max ${field.maxFileSize || 5}MB)

-
- - `; - if (field.uploadedFile) { - const uploadedFile = document.createElement('div'); - uploadedFile.className = 'uploaded-file'; - uploadedFile.innerHTML = ` -
- -
-
${field.uploadedFile.name}
-
${formatFileSize(field.uploadedFile.size)}
-
+ const fieldDiv = document.createElement('div'); + fieldDiv.className = `form-field ${state.selectedField && state.selectedField.id === field.id ? 'selected' : ''}`; + fieldDiv.dataset.fieldId = field.id; + fieldDiv.dataset.fieldIndex = index; + + const fieldHeader = document.createElement('div'); + fieldHeader.className = 'field-header'; + fieldHeader.innerHTML = ` +
+ + ${field.label || field.type.charAt(0).toUpperCase() + field.type.slice(1)} + ${field.required ? ' *' : ''} +
+
+
+ +
+ ${!field.predefined ? `
+ +
` : ''} +
+ `; + + const fieldContent = document.createElement('div'); + fieldContent.className = 'field-content'; + fieldContent.innerHTML = ` + + `; + + // Add field input based on type + if (field.type === 'text' || field.type === 'email' || field.type === 'phone' || field.type === 'date') { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'field-input'; + input.placeholder = field.placeholder || 'Enter value'; + input.disabled = true; + fieldContent.appendChild(input); + } else if (field.type === 'textarea') { + const textarea = document.createElement('textarea'); + textarea.className = 'field-input'; + textarea.rows = 3; + textarea.placeholder = field.placeholder || 'Enter text'; + textarea.disabled = true; + fieldContent.appendChild(textarea); + } else if (field.type === 'file') { + const fileUpload = document.createElement('div'); + fileUpload.className = 'file-upload-area'; + fileUpload.innerHTML = ` +
+ +
+
+

Drag & drop your ${field.label.toLowerCase()} here or click to browse

+
+
+

Supported formats: ${field.fileTypes || '.pdf, .doc, .docx'} (Max ${field.maxFileSize || 5}MB)

+ ${field.multipleFiles ? `

Multiple files allowed (Max ${field.maxFiles || 1} files)

` : ''} +
+ + `; + + // Show uploaded files + if (field.uploadedFiles && field.uploadedFiles.length > 0) { + field.uploadedFiles.forEach((file, fileIndex) => { + const uploadedFile = document.createElement('div'); + uploadedFile.className = 'uploaded-file'; + uploadedFile.innerHTML = ` +
+ +
+
${file.name}
+
${formatFileSize(file.size)}
- - `; - fileUpload.appendChild(uploadedFile); - } - fieldContent.appendChild(fileUpload); - } else if (field.type === 'select') { - const select = document.createElement('select'); - select.className = 'field-input'; - select.disabled = true; - field.options.forEach(option => { - const optionEl = document.createElement('option'); - optionEl.textContent = option; - select.appendChild(optionEl); - }); - fieldContent.appendChild(select); - } else if (field.type === 'radio' || field.type === 'checkbox') { - const optionsDiv = document.createElement('div'); - optionsDiv.className = 'field-options'; - field.options.forEach((option, idx) => { - const optionItem = document.createElement('div'); - optionItem.className = 'option-item'; - optionItem.innerHTML = ` - - - `; - optionsDiv.appendChild(optionItem); - }); - fieldContent.appendChild(optionsDiv); - } - fieldDiv.appendChild(fieldHeader); - fieldDiv.appendChild(fieldContent); - // Add event listeners - fieldDiv.addEventListener('click', (e) => { - if (!e.target.closest('.edit-field') && !e.target.closest('.remove-field') && - !e.target.closest('.remove-file-btn')) { - selectField(field); - } +
+ + `; + fileUpload.appendChild(uploadedFile); }); - const editBtn = fieldDiv.querySelector('.edit-field'); - if (editBtn) { - editBtn.addEventListener('click', (e) => { - e.stopPropagation(); - selectField(field); - }); - } - const removeBtn = fieldDiv.querySelector('.remove-field'); - if (removeBtn) { - removeBtn.addEventListener('click', (e) => { - e.stopPropagation(); - removeField(parseInt(removeBtn.dataset.fieldIndex)); - }); - } - const removeFileBtn = fieldDiv.querySelector('.remove-file-btn'); - if (removeFileBtn) { - removeFileBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const fieldId = parseInt(fieldDiv.dataset.fieldId); - const stage = state.stages[state.currentStage]; - const field = stage.fields.find(f => f.id === fieldId); - if (field) { - field.uploadedFile = null; - renderCurrentStage(); - } - }); - } - // Make draggable - 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); - dropField(targetIndex); - }); - return fieldDiv; } + fieldContent.appendChild(fileUpload); + } else if (field.type === 'select') { + const select = document.createElement('select'); + select.className = 'field-input'; + select.disabled = true; + field.options.forEach(option => { + const optionEl = document.createElement('option'); + optionEl.textContent = option; + select.appendChild(optionEl); + }); + fieldContent.appendChild(select); + } else if (field.type === 'radio' || field.type === 'checkbox') { + const optionsDiv = document.createElement('div'); + optionsDiv.className = 'field-options'; + field.options.forEach((option, idx) => { + const optionItem = document.createElement('div'); + optionItem.className = 'option-item'; + optionItem.innerHTML = ` + + + `; + optionsDiv.appendChild(optionItem); + }); + fieldContent.appendChild(optionsDiv); + } + + fieldDiv.appendChild(fieldHeader); + fieldDiv.appendChild(fieldContent); + + // Add event listeners + fieldDiv.addEventListener('click', (e) => { + if (!e.target.closest('.edit-field') && !e.target.closest('.remove-field') && + !e.target.closest('.remove-file-btn')) { + selectField(field); + } + }); + + const editBtn = fieldDiv.querySelector('.edit-field'); + if (editBtn) { + editBtn.addEventListener('click', (e) => { + e.stopPropagation(); + selectField(field); + }); + } + + const removeBtn = fieldDiv.querySelector('.remove-field'); + if (removeBtn) { + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + removeField(parseInt(removeBtn.dataset.fieldIndex)); + }); + } + + const removeFileBtns = fieldDiv.querySelectorAll('.remove-file-btn'); + removeFileBtns.forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const fileIndex = parseInt(btn.dataset.fileIndex); + const fieldId = parseInt(fieldDiv.dataset.fieldId); + const stage = state.stages[state.currentStage]; + const field = stage.fields.find(f => f.id === fieldId); + if (field && field.uploadedFiles) { + field.uploadedFiles.splice(fileIndex, 1); + renderCurrentStage(); + } + }); + }); + + // Make draggable + 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); + dropField(targetIndex); + }); + + // Add file input event listener + const fileInput = fieldDiv.querySelector('.file-input'); + if (fileInput) { + fileInput.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(); + }); + } + } + + return fieldDiv; +} +function handleFileUpload(event, field) { + const files = Array.from(event.target.files); + if (files.length === 0) return; + + // Validate file count for multiple files + if (field.multipleFiles) { + const maxFiles = field.maxFiles || 1; + if (files.length > maxFiles) { + alert(`You can only upload ${maxFiles} files for this field.`); + return; + } + } else if (files.length > 1) { + // For single file fields, only take the first file + files.splice(1); + } + + // Validate each file + const validFiles = []; + const allowedTypes = (field.fileTypes || '.pdf,.doc,.docx').split(',').map(type => type.trim().toLowerCase()); + const maxFileSize = field.maxFileSize || 5; + + for (const file of files) { + // Validate file type + const fileType = '.' + file.name.split('.').pop().toLowerCase(); + if (!allowedTypes.includes(fileType)) { + alert(`Invalid file type for ${file.name}. Allowed types: ${field.fileTypes || '.pdf, .doc, .docx'}`); + return; + } + + // Validate file size + const fileSizeMB = file.size / (1024 * 1024); + if (fileSizeMB > maxFileSize) { + alert(`File ${file.name} exceeds ${maxFileSize}MB limit.`); + return; + } + + validFiles.push(file); + } + + // Store the files + if (field.multipleFiles) { + // Initialize or update the uploadedFiles array + if (!field.uploadedFiles) { + field.uploadedFiles = []; + } + field.uploadedFiles = [...validFiles]; + } else { + // Single file - store as array with one file for consistency + field.uploadedFiles = [validFiles[0]]; + } + + // Re-render the current stage to show uploaded files + renderCurrentStage(); +} + function showFieldEditor(field) { elements.fieldEditor.style.display = 'flex'; elements.fieldLabel.value = field.label || ''; @@ -1499,30 +1628,51 @@ } function renderOptionsEditor(field) { - elements.optionsList.innerHTML = ''; - field.options.forEach((option, index) => { - const optionInput = document.createElement('div'); - optionInput.className = 'option-input'; - optionInput.innerHTML = ` - - - `; - elements.optionsList.appendChild(optionInput); - const input = optionInput.querySelector('input'); - const removeBtn = optionInput.querySelector('.remove-option'); - input.addEventListener('input', () => { - field.options[index] = input.value; - }); - removeBtn.addEventListener('click', () => { - if (field.options.length > 1) { - field.options.splice(index, 1); - renderOptionsEditor(field); + elements.optionsList.innerHTML = ''; + field.options.forEach((option, index) => { + const optionInput = document.createElement('div'); + optionInput.className = 'option-input'; + optionInput.innerHTML = ` + + + `; + elements.optionsList.appendChild(optionInput); + + const input = optionInput.querySelector('input'); + const removeBtn = optionInput.querySelector('.remove-option'); + + input.addEventListener('input', () => { + field.options[index] = input.value; + }); + + removeBtn.addEventListener('click', () => { + if (field.options.length > 1) { + field.options.splice(index, 1); + renderOptionsEditor(field); + } + }); + }); + + // Add event listener for multiple files checkbox if this is a file field + if (field.type === 'file') { + const multipleFilesCheckbox = elements.multipleFiles; + if (multipleFilesCheckbox) { + multipleFilesCheckbox.addEventListener('change', function() { + elements.maxFiles.disabled = !this.checked; + if (!this.checked) { + elements.maxFiles.value = 1; + // Update the field configuration + if (state.selectedField) { + state.selectedField.maxFiles = 1; } - }); + } }); } + } +} + // Event Handlers (same as before, but updated saveForm function) function selectField(field) { @@ -1668,29 +1818,33 @@ } function drop(event) { - event.preventDefault(); - event.target.classList.remove('drag-over'); - if (state.draggedField) { - const newField = { - id: state.nextFieldId++, - type: state.draggedField.type, - label: state.draggedField.label, - placeholder: '', - required: false, - options: state.draggedField.type === 'select' || state.draggedField.type === 'radio' || state.draggedField.type === 'checkbox' - ? ['Option 1', 'Option 2'] - : [], - fileTypes: state.draggedField.type === 'file' ? '.pdf,.doc,.docx' : '', - maxFileSize: state.draggedField.type === 'file' ? 5 : 0, - predefined: false, - uploadedFile: null - }; - state.stages[state.currentStage].fields.push(newField); - selectField(newField); - state.draggedField = null; - renderCurrentStage(); - } - } + event.preventDefault(); + event.target.classList.remove('drag-over'); + + if (state.draggedField) { + const newField = { + id: state.nextFieldId++, + type: state.draggedField.type, + label: state.draggedField.label, + placeholder: '', + required: false, + options: state.draggedField.type === 'select' || state.draggedField.type === 'radio' || state.draggedField.type === 'checkbox' + ? ['Option 1', 'Option 2'] + : [], + fileTypes: state.draggedField.type === 'file' ? '.pdf,.doc,.docx' : '', + maxFileSize: state.draggedField.type === 'file' ? 5 : 0, + multipleFiles: state.draggedField.type === 'file' ? false : undefined, + maxFiles: state.draggedField.type === 'file' ? 1 : undefined, + predefined: false, + uploadedFiles: state.draggedField.type === 'file' ? [] : undefined + }; + + state.stages[state.currentStage].fields.push(newField); + selectField(newField); + state.draggedField = null; + renderCurrentStage(); + } +} function dropField(targetIndex) { if (state.draggedFieldIndex !== null && state.draggedFieldIndex !== targetIndex) { @@ -1790,15 +1944,23 @@ // Initialize Application function init() { // Initialize form title - elements.formTitle.textContent = state.formName; + elements.formTitle.textContent = 'Loading...'; - renderStageNavigation(); - renderCurrentStage(); - initEventListeners(); - // Load existing template if editing - if (djangoConfig.loadUrl) { - loadExistingTemplate(); - } + // Hide the form stage initially to prevent flickering + elements.formStage.style.display = 'none'; + elements.emptyState.style.display = 'block'; + elements.emptyState.innerHTML = '

Loading form template...

'; + + // 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'; + renderStageNavigation(); + renderCurrentStage(); + } } // Start the application diff --git a/templates/forms/form_templates_list.html b/templates/forms/form_templates_list.html index 3f0dbfc..0194a88 100644 --- a/templates/forms/form_templates_list.html +++ b/templates/forms/form_templates_list.html @@ -1,5 +1,5 @@ {% extends 'base.html' %} -{% load static i18n %} +{% load static i18n crispy_forms_tags %} {% block title %}Form Templates - ATS{% endblock %} @@ -13,7 +13,7 @@ --kaauh-teal-dark: #004a53; --kaauh-border: #eaeff3; --kaauh-primary-text: #343a40; - --kaauh-gray-light: #f8f9fa; + --kaauh-gray-light: #f8f9fa; } /* --- Typography and Color Overrides --- */ @@ -25,7 +25,7 @@ border-color: var(--kaauh-teal); color: white; font-weight: 600; - padding: 0.375rem 0.75rem; + padding: 0.375rem 0.75rem; border-radius: 0.5rem; transition: all 0.2s ease; display: inline-flex; @@ -37,7 +37,7 @@ border-color: var(--kaauh-teal-dark); transform: translateY(-1px); box-shadow: 0 4px 8px rgba(0,0,0,0.15); - color: white; + color: white; } /* Secondary Button Style (for Edit/Preview) */ @@ -69,39 +69,41 @@ background-color: white; transition: transform 0.2s, box-shadow 0.2s; } - + /* Template Card Hover Effect (Consistent with job list card hover) */ .template-card { height: 100%; } .template-card:hover { - transform: translateY(-2px); + transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.1) !important; } - + /* Card Header Theming */ .card-header { /* FIX: Use !important to override default white/light backgrounds from Bootstrap */ - background-color: var(--kaauh-teal-dark) !important; + background-color: var(--kaauh-teal-dark) !important; border-bottom: 1px solid var(--kaauh-border); color: white !important; /* Base color for header text */ font-weight: 600; padding: 1rem 1.25rem; border-radius: 0.75rem 0.75rem 0 0; } - + /* Ensure all elements within the header are visible */ .card-header h3 { - color: white !important; + color: white !important; font-weight: 700; } .card-header .fas { - color: white !important; + color: white !important; } .card-header .small { color: rgba(255, 255, 255, 0.7) !important; } + /* Stats Theming */ + /* --- Content Styles (Stats, Description) --- */ .stat-value { font-size: 1.5rem; @@ -116,14 +118,13 @@ .card-description { min-height: 60px; color: var(--kaauh-primary-text); - margin-bottom: 1rem; } - /* --- Form/Search Input Theming (Matching Job List) --- */ - .form-control-search { - box-shadow: none; + /* Search Input Theming */ + .form-control { + border-radius: 0.5rem 0 0 0.5rem; border-color: var(--kaauh-border); - border-radius: 0 0.5rem 0.5rem 0; + border-radius: 0 0.5rem 0.5rem 0; } .form-control-search:focus { border-color: var(--kaauh-teal); @@ -146,8 +147,8 @@ --bs-btn-hover-bg: #dc3545; --bs-btn-hover-color: white; } - - /* --- Empty State Theming --- */ + + /* Empty State Theming */ .empty-state { text-align: center; padding: 3rem 1rem; @@ -159,7 +160,7 @@ .empty-state i { font-size: 3.5rem; margin-bottom: 1rem; - color: var(--kaauh-teal-dark); + color: var(--kaauh-teal-dark); } .empty-state .btn-main-action .fas { color: white !important; @@ -188,23 +189,23 @@

{% trans "Form Templates" %}

- - {% trans "Create New Template" %} - +
{# Search/Filter Area - Matching Job List Structure #}
Search Templates
-
- + +
@@ -214,7 +215,7 @@ - + {# Show Clear button if search is active #} {% if query %} @@ -236,13 +237,14 @@

{{ template.name }}

-
+ {{ template.job }} +
{{ template.created_at|date:"M d, Y" }} {{ template.updated_at|timesince }} {% trans "ago" %}
- + {# Content area - includes stats and description #}
@@ -263,7 +265,7 @@ {% endif %}

- + {# Action area - visually separated with pt-2 border-top #}
@@ -336,7 +338,33 @@ {% endif %}
-{% include 'includes/delete_modal.html' %} +{% include 'includes/delete_modal.html' %} + + + {% endblock %} {% block customJS %} @@ -387,7 +415,7 @@ window.location.href = query ? `?q=${encodeURIComponent(query)}` : '{% url "form_templates_list" %}'; } }); - + // Bind search form submit to the main button click event for consistency document.querySelector('.filter-buttons button[type="submit"]').addEventListener('click', function(e) { // Prevent default submission to handle URL construction correctly @@ -415,18 +443,18 @@ e.preventDefault(); if (!templateToDelete) return; - - // This CSRF token selector assumes it's present in your base template or form - const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; + + // This relies on 'csrfToken' being defined somewhere, which is typical for Django templates. + const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value; try { // NOTE: Update this URL to match your actual Django API endpoint for deletion - const response = await fetch(`/api/templates/${templateToDelete}/delete/`, { + const response = await fetch(`/api/templates/${templateToDelete}/delete/`, { method: 'DELETE', headers: { 'X-CSRFToken': csrfToken, 'X-Requested-With': 'XMLHttpRequest', - 'Content-Type': 'application/json' + 'Content-Type': 'application/json' } }); @@ -473,5 +501,50 @@ document.getElementById('deleteModal').addEventListener('hidden.bs.modal', function() { templateToDelete = null; }); + + // Handle create template form submission + document.getElementById('createTemplateForm').addEventListener('submit', async function(e) { + e.preventDefault(); + + const form = e.target; + const formData = new FormData(form); + + try { + const response = await fetch(form.action, { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + } + }); + + const result = await response.json(); + + if (response.ok && result.success) { + // Show success toast + createToast(result.message || 'Template created successfully!'); + + // Close modal + bootstrap.Modal.getInstance(document.getElementById('createTemplateModal')).hide(); + + // Clear form + form.reset(); + + // Redirect to form builder with new template ID + if (result.template_id) { + window.location.href = `{% url 'form_builder' %}${result.template_id}/`; + } else { + // Fallback to template list if no ID is returned + window.location.reload(); + } + } else { + // Show error toast + createToast('Error: ' + (result.message || 'Could not create template.'), 'error'); + } + } catch (error) { + console.error('Error:', error); + createToast('An error occurred while creating the template.', 'error'); + } + }); -{% endblock %} \ No newline at end of file +{% endblock %}