update the form builder

This commit is contained in:
ismail 2025-10-07 16:23:58 +03:00
parent 7f23cc18fb
commit c5c7963df5
27 changed files with 1249 additions and 420 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -57,6 +57,7 @@ urlpatterns = [
path('forms/builder/', views.form_builder, name='form_builder'),
path('forms/builder/<int:template_id>/', 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/<int:template_id>/', views.form_wizard_view, name='form_wizard'),
path('forms/form/<int:template_id>/submit/', views.submit_form, name='submit_form'),

View File

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

View File

@ -0,0 +1,208 @@
{% extends 'base.html' %}
{% load static i18n %}
{% load crispy_forms_tags %}
{% block title %}Create Form Template - ATS{% endblock %}
{% block customCSS %}
<style>
/* ================================================= */
/* THEME VARIABLES AND GLOBAL STYLES */
/* ================================================= */
:root {
--kaauh-teal: #00636e;
--kaauh-teal-dark: #004a53;
--kaauh-border: #eaeff3;
--kaauh-primary-text: #343a40;
}
/* Primary Color Overrides */
.text-primary { color: var(--kaauh-teal) !important; }
/* Main Action Button Style */
.btn-main-action, .btn-primary {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
color: white;
font-weight: 600;
padding: 0.6rem 1.2rem;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-main-action:hover, .btn-primary:hover {
background-color: var(--kaauh-teal-dark);
border-color: var(--kaauh-teal-dark);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
/* Card enhancements */
.card {
border: 1px solid var(--kaauh-border);
border-radius: 0.75rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
background-color: white;
}
/* Card Header Theming */
.card-header {
background-color: #f0f8ff !important; /* Light blue tint for header */
border-bottom: 1px solid var(--kaauh-border);
color: var(--kaauh-teal-dark);
font-weight: 600;
padding: 1rem 1.25rem;
border-radius: 0.75rem 0.75rem 0 0;
}
.card-header h3 {
color: var(--kaauh-teal-dark);
font-weight: 700;
}
.card-header .fas {
color: var(--kaauh-teal);
}
/* Form styling */
.form-control {
border-color: var(--kaauh-border);
border-radius: 0.5rem;
}
.form-control:focus {
border-color: var(--kaauh-teal);
box-shadow: 0 0 0 0.2rem rgba(0, 99, 110, 0.25);
}
.form-label {
font-weight: 500;
color: var(--kaauh-primary-text);
margin-bottom: 0.5rem;
}
.form-check-input:checked {
background-color: var(--kaauh-teal);
border-color: var(--kaauh-teal);
}
/* Modal styling */
.modal-content {
border-radius: 0.75rem;
border: 1px solid var(--kaauh-border);
}
.modal-header {
border-bottom: 1px solid var(--kaauh-border);
padding: 1.25rem 1.5rem;
background-color: #f0f8ff !important;
}
.modal-footer {
border-top: 1px solid var(--kaauh-border);
padding: 1rem 1.5rem;
}
/* Error message styling */
.invalid-feedback {
display: block;
width: 100%;
margin-top: 0.25rem;
font-size: 0.875em;
color: #dc3545;
}
.form-control.is-invalid {
border-color: #dc3545;
}
/* Success message styling */
.alert-success {
background-color: #d4edda;
border-color: #c3e6cb;
color: #155724;
padding: 1rem 1.25rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4">
<div class="d-flex justify-content-between align-items-center mb-4 pb-2 border-bottom border-primary">
<h1 class="h3 mb-0 fw-bold text-primary">
<i class="fas fa-file-alt me-2"></i>Create Form Template
</h1>
<a href="{% url 'form_templates_list' %}" class="btn btn-secondary">
<i class="fas fa-arrow-left me-1"></i>Back to Templates
</a>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h3 class="h5 mb-0"><i class="fas fa-plus-circle me-2"></i>New Form Template</h3>
</div>
<div class="card-body p-4">
<form method="post" id="createFormTemplate">
{% csrf_token %}
{{ form|crispy }}
<div class="d-flex justify-content-between">
<a href="{% url 'form_templates_list' %}" class="btn btn-secondary">
<i class="fas fa-times me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-main-action">
<i class="fas fa-save me-1"></i>Create Template
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% if messages %}
{% for message in messages %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle me-2"></i>{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// Add form validation
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('createFormTemplate');
form.addEventListener('submit', function(event) {
let isValid = true;
// Validate template name
const nameField = form.querySelector('#id_name');
if (!nameField.value.trim()) {
nameField.classList.add('is-invalid');
isValid = false;
} else {
nameField.classList.remove('is-invalid');
}
if (!isValid) {
event.preventDefault();
}
});
// Remove validation errors on input
const nameField = form.querySelector('#id_name');
nameField.addEventListener('input', function() {
if (this.value.trim()) {
this.classList.remove('is-invalid');
}
});
});
</script>
{% endblock %}

View File

@ -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 @@
<!-- Pass Django CSRF token and other data -->
<script>
// Django template variables - these will be processed by Django
const djangoConfig = {
csrfToken: "{{ csrf_token }}",
saveUrl: "{% url 'save_form_template' %}",
loadUrl: {% if template_id %}"{% url 'load_form_template' template_id %}"{% else %}null{% endif %},
templateId: {% if template_id %}{{ template_id }}{% else %}null{% endif %}
};
const djangoConfig = {
csrfToken: "{{ csrf_token }}",
saveUrl: "{% url 'save_form_template' %}",
loadUrl: {% if template_id %}"{% url 'load_form_template' template_id %}"{% else %}null{% endif %},
templateId: {% if template_id %}{{ template_id }}{% else %}null{% endif %},
jobId: {% if job_id %}{{ job_id }}{% else %}null{% endif %} // Add this if you need it
};
</script>
<div class="container">
<!-- Sidebar with form elements -->
<div class="sidebar">
<div class="sidebar-header">
<a class="" href="{% url 'form_templates_list' %}"></a>
<h2><i class="fas fa-cube"></i> Form Elements</h2>
</div>
<div class="field-categories">
@ -853,29 +857,51 @@
</div>
<!-- File Type Specific Settings -->
<div class="editor-section" id="fileSettings" style="display: none;">
<h4><i class="fas fa-file"></i> File Settings</h4>
<div class="form-group">
<label for="fileTypes">Allowed File Types</label>
<input
type="text"
id="fileTypes"
class="form-control"
placeholder=".pdf, .doc, .docx"
>
</div>
<div class="form-group">
<label for="maxFileSize">Max File Size (MB)</label>
<input
type="number"
id="maxFileSize"
class="form-control"
min="1"
max="100"
value="5"
>
</div>
</div>
</div>
<h4><i class="fas fa-file"></i> File Settings</h4>
<div class="form-group">
<label for="fileTypes">Allowed File Types</label>
<input
type="text"
id="fileTypes"
class="form-control"
placeholder=".pdf, .doc, .docx"
>
</div>
<div class="form-group">
<label for="maxFileSize">Max File Size (MB)</label>
<input
type="number"
id="maxFileSize"
class="form-control"
min="1"
max="100"
value="5"
>
</div>
<div class="form-group">
<div class="checkbox-group">
<input
type="checkbox"
id="multipleFiles"
>
<label for="multipleFiles">Allow Multiple Files</label>
</div>
<small class="form-text text-muted">Enable this to allow uploading multiple files for this field.</small>
</div>
<div class="form-group">
<label for="maxFiles">Maximum Number of Files</label>
<input
type="number"
id="maxFiles"
class="form-control"
min="1"
max="10"
value="1"
disabled
>
<small class="form-text text-muted">Only applicable when multiple files are allowed.</small>
</div>
</div>
</div>
</div>
</div>
@ -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 = '<i class="fas fa-exclamation-triangle"></i><p>Error loading template data.</p>';
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 = `
<div class="field-title">
<i class="${getFieldIcon(field.type)}"></i>
${field.label || field.type.charAt(0).toUpperCase() + field.type.slice(1)}
${field.required ? '<span class="required-indicator"> *</span>' : ''}
</div>
<div class="field-actions">
<div class="action-btn edit-field" data-field-id="${field.id}">
<i class="fas fa-edit"></i>
</div>
${!field.predefined ? `<div class="action-btn remove-field" data-field-index="${index}">
<i class="fas fa-trash"></i>
</div>` : ''}
</div>
`;
const fieldContent = document.createElement('div');
fieldContent.className = 'field-content';
fieldContent.innerHTML = `
<label class="field-label">
${field.label || 'Field Label'}
${field.required ? '<span class="required-indicator"> *</span>' : ''}
</label>
`;
// 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 = `
<div class="file-upload-icon">
<i class="fas fa-cloud-upload-alt"></i>
</div>
<div class="file-upload-text">
<p>Drag & drop your resume here or <strong>click to browse</strong></p>
</div>
<div class="file-upload-info">
<p>Supported formats: ${field.fileTypes || '.pdf, .doc, .docx'} (Max ${field.maxFileSize || 5}MB)</p>
</div>
<input type="file" class="file-input" style="display: none;" accept="${field.fileTypes || '.pdf,.doc,.docx'}">
`;
if (field.uploadedFile) {
const uploadedFile = document.createElement('div');
uploadedFile.className = 'uploaded-file';
uploadedFile.innerHTML = `
<div class="file-info">
<i class="fas fa-file file-icon"></i>
<div>
<div class="file-name">${field.uploadedFile.name}</div>
<div class="file-size">${formatFileSize(field.uploadedFile.size)}</div>
</div>
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 = `
<div class="field-title">
<i class="${getFieldIcon(field.type)}"></i>
${field.label || field.type.charAt(0).toUpperCase() + field.type.slice(1)}
${field.required ? '<span class="required-indicator"> *</span>' : ''}
</div>
<div class="field-actions">
<div class="action-btn edit-field" data-field-id="${field.id}">
<i class="fas fa-edit"></i>
</div>
${!field.predefined ? `<div class="action-btn remove-field" data-field-index="${index}">
<i class="fas fa-trash"></i>
</div>` : ''}
</div>
`;
const fieldContent = document.createElement('div');
fieldContent.className = 'field-content';
fieldContent.innerHTML = `
<label class="field-label">
${field.label || 'Field Label'}
${field.required ? '<span class="required-indicator"> *</span>' : ''}
</label>
`;
// 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 = `
<div class="file-upload-icon">
<i class="fas fa-cloud-upload-alt"></i>
</div>
<div class="file-upload-text">
<p>Drag & drop your ${field.label.toLowerCase()} here or <strong>click to browse</strong></p>
</div>
<div class="file-upload-info">
<p>Supported formats: ${field.fileTypes || '.pdf, .doc, .docx'} (Max ${field.maxFileSize || 5}MB)</p>
${field.multipleFiles ? `<p>Multiple files allowed (Max ${field.maxFiles || 1} files)</p>` : ''}
</div>
<input type="file" class="file-input" style="display: none;"
accept="${field.fileTypes || '.pdf,.doc,.docx'}"
${field.multipleFiles ? 'multiple' : ''}>
`;
// 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 = `
<div class="file-info">
<i class="fas fa-file file-icon"></i>
<div>
<div class="file-name">${file.name}</div>
<div class="file-size">${formatFileSize(file.size)}</div>
</div>
<button class="remove-file-btn">
<i class="fas fa-times"></i>
</button>
`;
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 = `
<input type="${field.type === 'radio' ? 'radio' : 'checkbox'}"
id="${field.type}-${field.id}-${idx}"
name="${field.type}-${field.id}"
disabled>
<label for="${field.type}-${field.id}-${idx}">${option}</label>
`;
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);
}
</div>
<button class="remove-file-btn" data-file-index="${fileIndex}">
<i class="fas fa-times"></i>
</button>
`;
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 = `
<input type="${field.type === 'radio' ? 'radio' : 'checkbox'}"
id="${field.type}-${field.id}-${idx}"
name="${field.type}-${field.id}"
disabled>
<label for="${field.type}-${field.id}-${idx}">${option}</label>
`;
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 = `
<input type="text" class="form-control" value="${option}" placeholder="Option ${index + 1}">
<button class="remove-option">
<i class="fas fa-times"></i>
</button>
`;
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 = `
<input type="text" class="form-control" value="${option}" placeholder="Option ${index + 1}">
<button class="remove-option">
<i class="fas fa-times"></i>
</button>
`;
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 = '<i class="fas fa-spinner fa-spin"></i><p>Loading form template...</p>';
// 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

View File

@ -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 @@
<h1 class="h3 mb-0 fw-bold" style="color: var(--kaauh-teal-dark); font-weight: 700;">
<i class="fas fa-file-alt me-2"></i>{% trans "Form Templates" %}
</h1>
<a href="{% url 'form_builder' %}" class="btn btn-main-action">
<i class="fas fa-plus me-1"></i> {% trans "Create New Template" %}
</a>
<button type="button" class="btn btn-main-action" data-bs-toggle="modal" data-bs-target="#createTemplateModal">
<i class="fas fa-plus me-1"></i> Create New Template
</button>
</div>
{# Search/Filter Area - Matching Job List Structure #}
<div class="card mb-4 shadow-sm no-hover">
<div class="card-body">
<h5 class="card-title text-muted mb-3" style="font-weight: 500;">Search Templates</h5>
<form method="get" class="row g-3 align-items-end">
<form method="get" class="row g-3 align-items-end">
<div class="col-md-6">
<label for="search" class="form-label small text-muted">Search by Template Name</label>
<div class="input-group input-group-lg input-group-search">
<span class="input-group-text"><i class="fas fa-search text-muted"></i></span>
<input type="text" name="q" id="searchInput" class="form-control form-control-search"
placeholder="{% trans 'Search templates by name...' %}"
<input type="text" name="q" id="searchInput" class="form-control form-control-search"
placeholder="{% trans 'Search templates by name...' %}"
value="{{ query|default_if_none:'' }}">
</div>
</div>
@ -214,7 +215,7 @@
<button type="submit" class="btn btn-main-action btn-lg">
<i class="fas fa-filter me-1"></i> Search
</button>
{# Show Clear button if search is active #}
{% if query %}
<a href="{% url 'form_templates_list' %}" class="btn btn-outline-danger btn-sm">
@ -236,13 +237,14 @@
<div class="card template-card h-100">
<div class="card-header ">
<h3 class="h5 mb-2">{{ template.name }}</h3>
<div class="d-flex justify-content-between small">
<span><i class="fas fa-sync-alt me-1"></i> {{ template.job }}</span>
<div class="d-flex justify-content-between text-muted small">
<span><i class="fas fa-calendar me-1"></i> {{ template.created_at|date:"M d, Y" }}</span>
<span><i class="fas fa-sync-alt me-1"></i> {{ template.updated_at|timesince }} {% trans "ago" %}</span>
</div>
</div>
<div class="card-body d-flex flex-column">
{# Content area - includes stats and description #}
<div class="flex-grow-1">
<div class="row text-center mb-3">
@ -263,7 +265,7 @@
{% endif %}
</p>
</div>
{# Action area - visually separated with pt-2 border-top #}
<div class="mt-auto pt-2 border-top">
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
@ -336,7 +338,33 @@
{% endif %}
</div>
{% include 'includes/delete_modal.html' %}
{% include 'includes/delete_modal.html' %}
<!-- Create Template Modal -->
<div class="modal fade" id="createTemplateModal" tabindex="-1" aria-labelledby="createTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createTemplateModalLabel">
<i class="fas fa-file-alt me-2"></i>Create New Form Template
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="createTemplateForm" method="post" action="{% url 'create_form_template' %}">
{% csrf_token %}
{{form|crispy}}
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" form="createTemplateForm" class="btn btn-primary">
<i class="fas fa-save me-1"></i>Create Template
</button>
</div>
</div>
</div>
</div>
{% 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');
}
});
</script>
{% endblock %}
{% endblock %}